diff --git a/.claude/skills/gc-component/SKILL.md b/.claude/skills/gc-component/SKILL.md deleted file mode 100644 index 8d674151..00000000 --- a/.claude/skills/gc-component/SKILL.md +++ /dev/null @@ -1,250 +0,0 @@ ---- -name: gc-component -description: Scaffold a new game-components Web Component (vanilla HTML5, Shadow DOM, no libraries) under `game-components/`. Triggers when the user asks to add/create/scaffold a `gc-*` component or a fantasy game UI element. Wires up the .ts file, SCSS partial, style index, src/index.ts export, plus the inventory and the published `examples/public/game-components/SKILL.md` downstream contract. ---- - -# gc-component - -Scaffold a single game-components Web Component matching the project's vanilla + fantasy-theme conventions. - -> **CSS rule (load-bearing):** All component CSS MUST live in `.scss` files under `game-components/style/components/`. The `.ts` file never contains a ` - - ) -} - -export default ToolcaseIconsDemo diff --git a/examples/src/react-components/TooltipDemo.tsx b/examples/src/react-components/TooltipDemo.tsx deleted file mode 100644 index a969a4ac..00000000 --- a/examples/src/react-components/TooltipDemo.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react' -import { - RichPageHeader, - RichPageHeaderChip, - SectionCard, - Tooltip -} from '@toolcase/react-components' - -const TooltipDemo: React.FC = () => ( -
-
-
- Overlays} - title="Tooltip" - description="Hover/focus tooltip that appears on top, bottom, left, or right of its child element." - /> -
- -
- - - - - - - - - - - - -
-
- - -
- Bold and italic content}> - Hover me - - - - -
-
-
- -
-
-
-) - -export default TooltipDemo diff --git a/examples/src/react-components/TreeViewDemo.tsx b/examples/src/react-components/TreeViewDemo.tsx deleted file mode 100644 index 7a6bca8b..00000000 --- a/examples/src/react-components/TreeViewDemo.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, { useState } from 'react' -import { - RichPageHeader, - RichPageHeaderChip, - SectionCard, - TreeNode, - TreeView -} from '@toolcase/react-components' - -const treeData: TreeNode[] = [ - { - key: 'src', - label: 'src', - icon: 'folder', - children: [ - { - key: 'components', - label: 'components', - icon: 'folder', - children: [ - { key: 'button', label: 'Button.tsx', icon: 'file-code' }, - { key: 'input', label: 'Input.tsx', icon: 'file-code' }, - ], - }, - { - key: 'hooks', - label: 'hooks', - icon: 'folder', - children: [ - { key: 'useauth', label: 'useAuth.ts', icon: 'file-code' }, - ], - }, - { key: 'app', label: 'App.tsx', icon: 'file-code' }, - { key: 'index', label: 'index.tsx', icon: 'file-code' }, - ], - }, - { - key: 'public', - label: 'public', - icon: 'folder', - children: [ - { key: 'favicon', label: 'favicon.ico', icon: 'file-image' }, - ], - }, - { key: 'package', label: 'package.json', icon: 'file-earmark-code' }, -] - -export const TreeViewDemo: React.FC = () => { - const [selected, setSelected] = useState([]) - const [expanded, setExpanded] = useState(['src']) - const [checked, setChecked] = useState([]) - const [exp2, setExp2] = useState([]) - - return ( -
-
-
- Data Display} - title="TreeView" - description="Hierarchical collapsible tree with keyboard navigation and optional checkboxes." - /> -
- - -

- Selected: {selected.join(', ') || '—'} -

-
- - - - -
- -
-
-
- ) -} diff --git a/examples/src/react-components/UsageSummaryPanelDemo.tsx b/examples/src/react-components/UsageSummaryPanelDemo.tsx deleted file mode 100644 index ef14907b..00000000 --- a/examples/src/react-components/UsageSummaryPanelDemo.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react' -import { - RichPageHeader, - RichPageHeaderChip, - SectionCard, - UsageSummaryPanel -} from '@toolcase/react-components' - -const UsageSummaryPanelDemo: React.FC = () => ( -
-
-
- Dashboard & Admin} - title="UsageSummaryPanel" - description="A usage breakdown panel with labeled progress meters and warning thresholds." - /> -
- - - - - - - - - - - -
- -
-
-
-) - -export default UsageSummaryPanelDemo diff --git a/examples/src/react-components/UserPanelDemo.tsx b/examples/src/react-components/UserPanelDemo.tsx deleted file mode 100644 index 5852da4f..00000000 --- a/examples/src/react-components/UserPanelDemo.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react' -import { - RichPageHeader, - RichPageHeaderChip, - SectionCard, - UserPanel -} from '@toolcase/react-components' - -const UserPanelDemo: React.FC = () => ( -
-
-
- Dashboard & Admin} - title="UserPanel" - description={<>Designed to be used as sidebarPanelComponent in DashboardLayout.} - /> -
- -
- -
-
- - -
- -
-
- - -
- -
-
- - -
- {['Free', 'Starter', 'Pro', 'Business', 'Enterprise'].map((plan) => ( -
- -
- ))} -
-
- - -
- -
-
- - -
- console.log('menu:', key)} - /> -
-
-
- -
-
-
-) - -export default UserPanelDemo diff --git a/examples/src/react-components/VersionPickerDemo.tsx b/examples/src/react-components/VersionPickerDemo.tsx deleted file mode 100644 index ca0cbaf8..00000000 --- a/examples/src/react-components/VersionPickerDemo.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { useState } from 'react' -import { VersionPicker, RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const versions = [ - { label: 'v1.x', value: '1', deprecated: true }, - { label: 'v2.x', value: '2', lts: true }, - { label: 'v3.x', value: '3', latest: true }, -] - -const VersionPickerDemo: React.FC = () => { - const [v, setV] = useState('3') - return ( -
-
-
- Roadmap & Releases} - title="VersionPicker" - description="Switch between docs/release versions with latest/LTS callouts." - /> -
- - -
Selected: v{v}.x
-
- - - -
-
-
-
- ) -} - -export default VersionPickerDemo diff --git a/examples/src/react-components/VerticalItemListDemo.tsx b/examples/src/react-components/VerticalItemListDemo.tsx deleted file mode 100644 index 75d72891..00000000 --- a/examples/src/react-components/VerticalItemListDemo.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { useState } from 'react' -import { - RichPageHeader, - RichPageHeaderChip, - SectionCard, - VerticalItemList, - VerticalItemListItem -} from '@toolcase/react-components' - -const ITEMS: VerticalItemListItem[] = [ - { key: 'dashboard', icon: 'grid', text: 'Dashboard', badge: 3 }, - { key: 'users', icon: 'people', text: 'Users', badge: 12 }, - { key: 'settings', icon: 'gear', text: 'Settings' }, - { key: 'files', icon: 'folder', text: 'Files', badge: '2 GB' }, - { key: 'analytics', icon: 'graph-up', text: 'Analytics' }, -] - -const CONTENT: Record = { - dashboard: { title: 'Dashboard', description: 'Overview of your project metrics and recent activity.' }, - users: { title: 'Users', description: 'Manage team members, roles, and permissions.' }, - settings: { title: 'Settings', description: 'Configure project preferences and integrations.' }, - files: { title: 'Files', description: 'Browse and manage uploaded assets and documents.' }, - analytics: { title: 'Analytics', description: 'View traffic, usage stats, and performance charts.' }, -} - -const VerticalItemListDemo = () => { - const [selected, setSelected] = useState('dashboard') - const content = CONTENT[selected] - - return ( -
-
-
- Data Display} - title="VerticalItemList" - description="A sidebar list with icons, badges, and active state — paired with a content panel." - /> -
- -
- - {content && ( -
-

{content.title}

-

{content.description}

-
- )} -
-
-
-
- -
-
-
- ) -} - -export default VerticalItemListDemo diff --git a/examples/src/react-components/VideoEmbedDemo.tsx b/examples/src/react-components/VideoEmbedDemo.tsx deleted file mode 100644 index 7e0cec9f..00000000 --- a/examples/src/react-components/VideoEmbedDemo.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import { VideoEmbed, RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const VideoEmbedDemo: React.FC = () => ( -
-
-
- Footer & Misc} - title="VideoEmbed" - description="Responsive 16:9 video container with iframe/
-
-
-) - -export default VideoEmbedDemo diff --git a/examples/src/react-components/VirtualListDemo.tsx b/examples/src/react-components/VirtualListDemo.tsx deleted file mode 100644 index d4f075d2..00000000 --- a/examples/src/react-components/VirtualListDemo.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react' -import { - RichPageHeader, - RichPageHeaderChip, - SectionCard, - VirtualList -} from '@toolcase/react-components' - -interface Item { - id: number - title: string - description: string -} - -const ITEMS: Item[] = Array.from({ length: 10000 }, (_, i) => ({ - id: i, - title: `Item #${i + 1}`, - description: `This is the description for item ${i + 1}. It contains some text.`, -})) - -export const VirtualListDemo: React.FC = () => { - return ( -
-
-
- Layout & Surfaces} - title="VirtualList" - description="Renders 10,000 items — only the visible rows are in the DOM." - /> -
- - ( -
- {item.title} - {item.description} -
- )} - /> -
-
- -
-
-
- ) -} diff --git a/examples/src/react-components/VisuallyHiddenDemo.tsx b/examples/src/react-components/VisuallyHiddenDemo.tsx deleted file mode 100644 index 3ceaa8f5..00000000 --- a/examples/src/react-components/VisuallyHiddenDemo.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react' -import { - RichPageHeader, - RichPageHeaderChip, - SectionCard, - VisuallyHidden -} from '@toolcase/react-components' - -const VisuallyHiddenDemo: React.FC = () => ( -
-
-
- Typography} - title="VisuallyHidden" - description="Hides content visually while keeping it accessible to screen readers. Use the browser dev tools or a screen reader to confirm the hidden text is in the DOM." - /> -
- - - - - - - Skip to main content - -

The hidden text "Skip to main content" exists in the DOM above this paragraph—inspect to verify.

-
-
- -
-
-
-) - -export default VisuallyHiddenDemo diff --git a/examples/src/react-components/WelcomeGuideDemo.tsx b/examples/src/react-components/WelcomeGuideDemo.tsx deleted file mode 100644 index ad708be6..00000000 --- a/examples/src/react-components/WelcomeGuideDemo.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useState } from 'react' -import { - Button, - RichPageHeader, - RichPageHeaderChip, - SectionCard, - WelcomeGuide, - WelcomeGuideStep -} from '@toolcase/react-components' - -const INITIAL_STEPS: WelcomeGuideStep[] = [ - { key: 'create-project', label: 'Create your first project', completed: true }, - { key: 'upload-asset', label: 'Upload an asset', completed: true }, - { key: 'configure-settings', label: 'Configure game settings', completed: false }, - { key: 'invite-member', label: 'Invite a team member', completed: false }, - { key: 'publish-build', label: 'Publish a build', completed: false }, -] - -const MESSAGES = [ - 'Welcome to Webgame Cloud! Follow the steps on the right to get up and running.', - 'Each step will guide you through a core feature of the platform.', - 'You can revisit this guide anytime from your dashboard.', -] - -const DotPattern = () => ( - - - - - - - - -) - -const WelcomeGuideDemo = () => { - const [steps, setSteps] = useState(INITIAL_STEPS) - const [showPattern, setShowPattern] = useState(true) - - const toggleStep = (index: number) => { - setSteps((prev) => - prev.map((s, i) => (i === index ? { ...s, completed: !s.completed } : s)) - ) - } - - return ( -
-
-
- Marketing} - title="WelcomeGuide" - description="An onboarding card with step progress, messages carousel, and optional background pattern." - /> -
- - : undefined} - /> - - - - - - - -
- {steps.map((step, i) => ( - - ))} - -
-
-
- -
-
-
- ) -} - -export default WelcomeGuideDemo diff --git a/examples/src/react-components/_demo/DemoGrid.tsx b/examples/src/react-components/_demo/DemoGrid.tsx deleted file mode 100644 index d26a65c5..00000000 --- a/examples/src/react-components/_demo/DemoGrid.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { FC, ReactNode, CSSProperties } from 'react' - -export interface DemoGridProps { - columns?: number - minItemWidth?: number - gap?: number - children: ReactNode -} - -/** - * Responsive auto-fit grid for laying out demo variants side-by-side. - * `columns` = hard cap; `minItemWidth` = min size before wrapping. - */ -export const DemoGrid: FC = ({ - columns, - minItemWidth = 240, - gap = 16, - children, -}) => { - const style: CSSProperties = { - display: 'grid', - gap, - gridTemplateColumns: columns - ? `repeat(${columns}, minmax(0, 1fr))` - : `repeat(auto-fit, minmax(${minItemWidth}px, 1fr))`, - } - return
{children}
-} - -export default DemoGrid diff --git a/examples/src/react-components/_demo/DemoPage.tsx b/examples/src/react-components/_demo/DemoPage.tsx deleted file mode 100644 index 1d5dc79f..00000000 --- a/examples/src/react-components/_demo/DemoPage.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { FC, ReactNode } from 'react' -import './demo-shell.css' - -export interface DemoPageProps { - eyebrow?: string - title: string - lede?: ReactNode - children?: ReactNode -} - -/** - * Container + masthead for a component demo page. - * Used by every demo in `examples/src/react-components/`. - */ -export const DemoPage: FC = ({ eyebrow, title, lede, children }) => ( -
-
- {eyebrow && {eyebrow}} -

{title}

- {lede &&

{lede}

} -
-
{children}
-
-) - -export default DemoPage diff --git a/examples/src/react-components/_demo/DemoRow.tsx b/examples/src/react-components/_demo/DemoRow.tsx deleted file mode 100644 index 26f0ba46..00000000 --- a/examples/src/react-components/_demo/DemoRow.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { FC, ReactNode } from 'react' - -export interface DemoRowProps { - label?: ReactNode - children: ReactNode -} - -/** - * A labeled row for showing one variant/state side-by-side with its name. - * Great for "Default · Loading · Disabled" style displays. - */ -export const DemoRow: FC = ({ label, children }) => ( -
- {label &&
{label}
} -
{children}
-
-) - -export default DemoRow diff --git a/examples/src/react-components/_demo/DemoSection.tsx b/examples/src/react-components/_demo/DemoSection.tsx deleted file mode 100644 index e1595285..00000000 --- a/examples/src/react-components/_demo/DemoSection.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { FC, ReactNode } from 'react' - -export interface DemoSectionProps { - eyebrow?: string - title: ReactNode - caption?: ReactNode - aside?: ReactNode - children: ReactNode - flush?: boolean -} - -/** - * A titled section inside a DemoPage. Renders an eyebrow / title / caption - * masthead followed by a flat content surface. `flush` removes the padding - * around the body for cases where the child already has its own container. - */ -export const DemoSection: FC = ({ - eyebrow, - title, - caption, - aside, - flush, - children, -}) => ( -
-
-
- {eyebrow && {eyebrow}} -

{title}

- {caption &&

{caption}

} -
- {aside &&
{aside}
} -
-
{children}
-
-) - -export default DemoSection diff --git a/examples/src/react-components/_demo/demo-shell.css b/examples/src/react-components/_demo/demo-shell.css deleted file mode 100644 index 9789b386..00000000 --- a/examples/src/react-components/_demo/demo-shell.css +++ /dev/null @@ -1,179 +0,0 @@ -/* ── Demo shell: shared chrome for every component demo ──────────────── */ - -.demo-page { - max-width: 1200px; - margin: 0 auto; - padding: 40px 32px 80px; - color: #1e293b; -} - -.demo-page__masthead { - display: flex; - flex-direction: column; - gap: 6px; - padding-bottom: 28px; - margin-bottom: 28px; - border-bottom: 1px solid #e2e8f0; -} - -.demo-page__eyebrow { - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 0.72rem; - font-weight: 600; - color: #64748b; -} - -.demo-page__title { - margin: 0; - font-size: clamp(1.75rem, 3vw, 2.25rem); - font-weight: 800; - letter-spacing: -0.022em; - color: #0f172a; - line-height: 1.15; -} - -.demo-page__lede { - margin: 4px 0 0; - color: #475569; - font-size: 1rem; - line-height: 1.55; - max-width: 70ch; -} - -.demo-page__lede code { - background: #f1f5f9; - border: 1px solid #e2e8f0; - padding: 0.05rem 0.375rem; - font-size: 0.9em; - font-family: 'JetBrains Mono', 'SF Mono', Monaco, Consolas, monospace; - color: #0f172a; -} - -.demo-page__body { - display: flex; - flex-direction: column; - gap: 32px; -} - -/* ── Section */ - -.demo-section { - background: #ffffff; - border: 1px solid #e2e8f0; - display: flex; - flex-direction: column; -} - -.demo-section__head { - display: flex; - gap: 16px; - justify-content: space-between; - align-items: flex-start; - padding: 18px 22px 14px; - border-bottom: 1px solid #f1f5f9; -} - -.demo-section__head-text { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.demo-section__eyebrow { - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 0.68rem; - font-weight: 600; - color: #64748b; -} - -.demo-section__title { - margin: 0; - font-size: 1rem; - font-weight: 600; - letter-spacing: -0.01em; - color: #0f172a; -} - -.demo-section__caption { - margin: 2px 0 0; - color: #64748b; - font-size: 0.85rem; - line-height: 1.5; - max-width: 70ch; -} - -.demo-section__aside { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 8px; - color: #64748b; - font-size: 0.85rem; -} - -.demo-section__body { - padding: 22px; -} - -.demo-section__body--flush { - padding: 0; -} - -/* ── Demo row: labeled variant */ - -.demo-row { - display: grid; - grid-template-columns: 140px 1fr; - gap: 20px; - padding: 12px 0; - align-items: flex-start; - border-bottom: 1px dashed #f1f5f9; -} - -.demo-row:last-child { - border-bottom: none; - padding-bottom: 0; -} - -.demo-row:first-child { - padding-top: 0; -} - -.demo-row__label { - font-size: 0.8rem; - font-weight: 600; - color: #64748b; - text-transform: uppercase; - letter-spacing: 0.04em; - padding-top: 6px; -} - -.demo-row__content { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 10px; - min-width: 0; -} - -@media (max-width: 720px) { - .demo-page { - padding: 24px 16px 64px; - } - - .demo-section__body { - padding: 16px; - } - - .demo-row { - grid-template-columns: 1fr; - gap: 8px; - } - - .demo-row__label { - padding-top: 0; - } -} diff --git a/examples/src/react-components/_demo/index.ts b/examples/src/react-components/_demo/index.ts deleted file mode 100644 index 2b5bb3ef..00000000 --- a/examples/src/react-components/_demo/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { DemoPage } from './DemoPage' -export type { DemoPageProps } from './DemoPage' -export { DemoSection } from './DemoSection' -export type { DemoSectionProps } from './DemoSection' -export { DemoGrid } from './DemoGrid' -export type { DemoGridProps } from './DemoGrid' -export { DemoRow } from './DemoRow' -export type { DemoRowProps } from './DemoRow' diff --git a/examples/src/react-components/demo.css b/examples/src/react-components/demo.css deleted file mode 100644 index 71632567..00000000 --- a/examples/src/react-components/demo.css +++ /dev/null @@ -1,153 +0,0 @@ -body, -html { - background: #f1f5f9; - margin: 0; - padding: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; -} - -/* ── Example page (individual demo) ── */ - -.example { - width: 100vw; - min-height: 100vh; - background: #f1f5f9; -} - -.example__back { - position: sticky; - top: 0; - z-index: 10000; - background: rgba(241, 245, 249, 0.85); - backdrop-filter: blur(8px); - border-bottom: 1px solid #e2e8f0; - padding: 12px 24px; -} - -.example__back a { - color: #475569; - text-decoration: none; - font-size: 14px; - font-weight: 500; - display: inline-flex; - align-items: center; - gap: 6px; - transition: color 0.15s; -} - -.example__back a:hover { - color: #0f172a; -} - -/* ── Menu / home page ── */ - -.example-menu { - max-width: 900px; - margin: 0 auto; - padding: 48px 24px 64px; -} - -.example-menu__header { - margin-bottom: 32px; -} - -.example-menu__header h1 { - margin: 0 0 4px; - font-size: 28px; - font-weight: 700; - color: #0f172a; - letter-spacing: -0.5px; -} - -.example-menu__header p { - margin: 0; - font-size: 14px; - color: #94a3b8; - font-weight: 500; -} - -.example-menu__grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 12px; -} - -/* ── Category sections ── */ - -.example-menu__category { - margin-bottom: 32px; -} - -.example-menu__category-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; - padding-bottom: 8px; - border-bottom: 1px solid #e2e8f0; -} - -.example-menu__category-header i { - font-size: 16px; - color: #64748b; -} - -.example-menu__category-header h2 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #334155; - letter-spacing: -0.2px; -} - -.example-menu__category-count { - font-size: 12px; - font-weight: 600; - color: #94a3b8; - background: #f1f5f9; - padding: 1px 8px; - border-radius: 10px; -} - -.example-menu__card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 10px; - text-decoration: none; - color: #1e293b; - transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; -} - -.example-menu__card:hover { - border-color: #cbd5e1; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - transform: translateY(-1px); -} - -.example-menu__card-label { - font-size: 15px; - font-weight: 500; -} - -.example-menu__card-arrow { - color: #cbd5e1; - font-size: 14px; - transition: color 0.15s, transform 0.15s; -} - -.example-menu__card:hover .example-menu__card-arrow { - color: #64748b; - transform: translateX(2px); -} - -.source-button { - display: none !important; -} - -.bs-component { - margin-top: 1rem !important; -} diff --git a/examples/src/react-components/index.tsx b/examples/src/react-components/index.tsx deleted file mode 100644 index 652455cc..00000000 --- a/examples/src/react-components/index.tsx +++ /dev/null @@ -1,439 +0,0 @@ -import { JSX } from 'react' - -import { AccordionDemo } from './AccordionDemo' -import ActionHeaderDemo from './ActionHeaderDemo' -import ActionItemsDemo from './ActionItemsDemo' -import ActionRowListDemo from './ActionRowListDemo' -import AdvancedTableDemo from './AdvancedTableDemo' -import AlertDemo from './AlertDemo' -import AssetBundleDemo from './AssetBundleDemo' -import AssetRowDemo from './AssetRowDemo' -import AvatarDemo from './AvatarDemo' -import BadgeDemo from './BadgeDemo' -import { BannerDemo } from './BannerDemo' -import BasicLayoutDemo from './BasicLayoutDemo' -import BitmapFontGeneratorDemo from './BitmapFontGeneratorDemo' -import NormalMapGeneratorDemo from './NormalMapGeneratorDemo' -import PhysicsEditorDemo from './PhysicsEditorDemo' -import BrandDemo from './BrandDemo' -import BreadcrumbDemo from './BreadcrumbDemo' -import BuildDemo from './BuildDemo' -import BundleBarDemo from './BundleBarDemo' -import ButtonDemo from './ButtonDemo' -import CardDemo from './CardDemo' -import CardOptionsDemo from './CardOptionsDemo' -import { CarouselDemo } from './CarouselDemo' -import CdnMapDemo from './CdnMapDemo' -import ChangelogDemo from './ChangelogDemo' -import { ChartDemo } from './ChartDemo' -import CheckboxDemo from './CheckboxDemo' -import CheckboxGroupDemo from './CheckboxGroupDemo' -import ChipDemo from './ChipDemo' -import CodeLabelCellDemo from './CodeLabelCellDemo' -import CodeSnippetDemo from './CodeSnippetDemo' -import ColorPickerDemo from './ColorPickerDemo' -import ComparatorDemo from './ComparatorDemo' -import AnnouncementBarDemo from './AnnouncementBarDemo' -import BadgeRowDemo from './BadgeRowDemo' -import TerminalWindowDemo from './TerminalWindowDemo' -import InstallTabsDemo from './InstallTabsDemo' -import QuickStartDemo from './QuickStartDemo' -import CodeWithOutputDemo from './CodeWithOutputDemo' -import DiffViewerDemo from './DiffViewerDemo' -import LogoCloudDemo from './LogoCloudDemo' -import TestimonialCarouselDemo from './TestimonialCarouselDemo' -import DownloadStatsDemo from './DownloadStatsDemo' -import GithubStarsCardDemo from './GithubStarsCardDemo' -import ContributorWallDemo from './ContributorWallDemo' -import SponsorWallDemo from './SponsorWallDemo' -import FeatureMatrixDemo from './FeatureMatrixDemo' -import CompatibilityMatrixDemo from './CompatibilityMatrixDemo' -import ApiReferenceTableDemo from './ApiReferenceTableDemo' -import CommandReferenceDemo from './CommandReferenceDemo' -import CookbookGridDemo from './CookbookGridDemo' -import CommunityLinksDemo from './CommunityLinksDemo' -import MaintainerCardDemo from './MaintainerCardDemo' -import PluginGridDemo from './PluginGridDemo' -import EcosystemMapDemo from './EcosystemMapDemo' -import GoodFirstIssuesDemo from './GoodFirstIssuesDemo' -import BenchmarkChartDemo from './BenchmarkChartDemo' -import RoadmapDemo from './RoadmapDemo' -import VersionPickerDemo from './VersionPickerDemo' -import MigrationGuideDemo from './MigrationGuideDemo' -import NewsletterSignupDemo from './NewsletterSignupDemo' -import SocialLinksDemo from './SocialLinksDemo' -import VideoEmbedDemo from './VideoEmbedDemo' -import FAQListDemo from './FAQListDemo' -import CalloutQuoteDemo from './CalloutQuoteDemo' -import CommandPaletteDemo from './CommandPaletteDemo' -import ConfigPreviewDemo from './ConfigPreviewDemo' -import { ContextMenuDemo } from './ContextMenuDemo' -import CoolButtonDemo from './CoolButtonDemo' -import CoolNavDemo from './CoolNavDemo' -import DangerZoneActionsDemo from './DangerZoneActionsDemo' -import DashboardCardDemo from './DashboardCardDemo' -import { DashboardLayoutDemo } from './DashboardLayoutDemo' -import DatePickerDemo from './DatePickerDemo' -import DividerDemo from './DividerDemo' -import { DrawerDemo } from './DrawerDemo' -import DropdownDemo from './DropdownDemo' -import EarlySignupFormDemo from './EarlySignupFormDemo' -import EmptyStateDemo from './EmptyStateDemo' -import EntityCellDemo from './EntityCellDemo' -import EntityProfileCardDemo from './EntityProfileCardDemo' -import ExtendedSelectDemo from './ExtendedSelectDemo' -import FeatureCardDemo from './FeatureCardDemo' -import FileDemo from './FileDemo' -import FileDropzoneDemo from './FileDropzoneDemo' -import FileTagsDemo from './FileTagsDemo' -import FormDemo from './FormDemo' -import FormInputDemo from './FormInputDemo' -import FormWizardDemo from './FormWizardDemo' -import GroupDemo from './GroupDemo' -import HeadingDemo from './HeadingDemo' -import HelperTextDemo from './HelperTextDemo' -import HeroDemo from './HeroDemo' -import IconButtonDemo from './IconButtonDemo' -import IconDemo from './IconDemo' -import IconPickerDemo from './IconPickerDemo' -import ImageDemo from './ImageDemo' -import { ImageCropDemo } from './ImageCropDemo' -import { InfiniteScrollDemo } from './InfiniteScrollDemo' -import InputDemo from './InputDemo' -import JSONEditorDemo from './JSONEditorDemo' -import JSONSchemaDefDemo from './JSONSchemaDefDemo' -import KbdDemo from './KbdDemo' -import LabelDemo from './LabelDemo' -import { LightboxDemo } from './LightboxDemo' -import LinkDemo from './LinkDemo' -import LinkedProvidersCardDemo from './LinkedProvidersCardDemo' -import LoginDemo from './LoginDemo' -import { MarkdownEditorDemo } from './MarkdownEditorDemo' -import MetricGridDemo from './MetricGridDemo' -import { ModalDemo } from './ModalDemo' -import MultiCardSelectDemo from './MultiCardSelectDemo' -import NodeEditorDemo from './NodeEditorDemo' -import AudioMixerDemo from './AudioMixerDemo' -import { NumberInputDemo } from './NumberInputDemo' -import { OTPInputDemo } from './OTPInputDemo' -import PageFooterDemo from './PageFooterDemo' -import PaginationDemo from './PaginationDemo' -import { PhoneInputDemo } from './PhoneInputDemo' -import PinnedFeatureShowcaseDemo from './PinnedFeatureShowcaseDemo' -import PipelineDemo from './PipelineDemo' -import PopoverDemo from './PopoverDemo' -import PricingCardDemo from './PricingCardDemo' -import ProgressBarDemo from './ProgressBarDemo' -import QueuedFileDemo from './QueuedFileDemo' -import RadioDemo from './RadioDemo' -import RadioGroupDemo from './RadioGroupDemo' -import { RangeSliderDemo } from './RangeSliderDemo' -import { RatingDemo } from './RatingDemo' -import { ResizablePanelDemo } from './ResizablePanelDemo' -import RichPageHeaderDemo from './RichPageHeaderDemo' -import { ScrollAreaDemo } from './ScrollAreaDemo' -import SectionCardDemo from './SectionCardDemo' -import SelectDemo from './SelectDemo' -import SideNavDemo from './SideNavDemo' -import { SimpleFileDemo } from './SimpleFileDemo' -import SingleCardSelectDemo from './SingleCardSelectDemo' -import SkeletonDemo from './SkeletonDemo' -import { SliderDemo } from './SliderDemo' -import SpacerDemo from './SpacerDemo' -import SpinnerDemo from './SpinnerDemo' -import StatCardDemo from './StatCardDemo' -import StatusDotDemo from './StatusDotDemo' -import { StepperDemo } from './StepperDemo' -import SwitchDemo from './SwitchDemo' -import TabSectionsDemo from './TabSectionsDemo' -import TableDemo from './TableDemo' -import TagDemo from './TagDemo' -import TagInputDemo from './TagInputDemo' -import TeamListDemo from './TeamListDemo' -import TextDemo from './TextDemo' -import TextareaDemo from './TextareaDemo' -import { TimePickerDemo } from './TimePickerDemo' -import TimelineDemo from './TimelineDemo' -import ToolcaseIconsDemo from './ToolcaseIconsDemo' -import { ToastDemo } from './ToastDemo' -import ToggleCardDemo from './ToggleCardDemo' -import TooltipDemo from './TooltipDemo' -import { TreeViewDemo } from './TreeViewDemo' -import UsageSummaryPanelDemo from './UsageSummaryPanelDemo' -import UserPanelDemo from './UserPanelDemo' -import VerticalItemListDemo from './VerticalItemListDemo' -import { VirtualListDemo } from './VirtualListDemo' -import VisuallyHiddenDemo from './VisuallyHiddenDemo' -import WelcomeGuideDemo from './WelcomeGuideDemo' - -// Game Jam / Arcade demos -import MarqueeDemo from './MarqueeDemo' -import CountdownTimerDemo from './CountdownTimerDemo' -import CycleWheelDemo from './CycleWheelDemo' -import SprintChainDemo from './SprintChainDemo' -import BriefCardDemo from './BriefCardDemo' -import StateMachineDemo from './StateMachineDemo' -import ChipGroupDemo from './ChipGroupDemo' -import PhaseGridDemo from './PhaseGridDemo' -import ScoringRulesDemo from './ScoringRulesDemo' -import TierLadderDemo from './TierLadderDemo' -import LeaderboardDemo from './LeaderboardDemo' -import LeaderboardTrendDemo from './LeaderboardTrendDemo' -import RankCellDemo from './RankCellDemo' -import StampDemo from './StampDemo' -import GameShowcaseCardDemo from './GameShowcaseCardDemo' -import LiveFeedDemo from './LiveFeedDemo' -import PulseIndicatorDemo from './PulseIndicatorDemo' -import SectionFlagDemo from './SectionFlagDemo' -import HeroStatsBarDemo from './HeroStatsBarDemo' - -export type ExampleCategory = - | 'Typography & Decoration' - | 'Inputs & Forms' - | 'Buttons & Actions' - | 'Layout & Structure' - | 'Navigation' - | 'Overlays & Feedback' - | 'Data Display' - | 'Charts & Metrics' - | 'Media & Files' - | 'Identity & People' - | 'Marketing & Landing' - | 'Code & Docs' - | 'Game Jam / Arcade' - -export type ExampleDef = { - key: string - category: ExampleCategory - element: JSX.Element -} - -export const categories: ExampleCategory[] = [ - 'Typography & Decoration', - 'Inputs & Forms', - 'Buttons & Actions', - 'Layout & Structure', - 'Navigation', - 'Overlays & Feedback', - 'Data Display', - 'Charts & Metrics', - 'Media & Files', - 'Identity & People', - 'Marketing & Landing', - 'Code & Docs', - 'Game Jam / Arcade', -] - -// Demos are grouped by purpose. Within each category, items are roughly -// ordered light → heavy so prev/next navigation flows simple → complex. -export const examples: ExampleDef[] = [ - // ── Typography & Decoration ────────────────────────────────────── - { key: 'visually-hidden', category: 'Typography & Decoration', element: }, - { key: 'divider', category: 'Typography & Decoration', element: }, - { key: 'spacer', category: 'Typography & Decoration', element: }, - { key: 'kbd', category: 'Typography & Decoration', element: }, - { key: 'link', category: 'Typography & Decoration', element: }, - { key: 'text', category: 'Typography & Decoration', element: }, - { key: 'heading', category: 'Typography & Decoration', element: }, - { key: 'status-dot', category: 'Typography & Decoration', element: }, - { key: 'brand', category: 'Typography & Decoration', element: }, - { key: 'badge', category: 'Typography & Decoration', element: }, - { key: 'tag', category: 'Typography & Decoration', element: }, - { key: 'chip', category: 'Typography & Decoration', element: }, - { key: 'icon', category: 'Typography & Decoration', element: }, - { key: 'toolcase-icons', category: 'Typography & Decoration', element: }, - - // ── Inputs & Forms ─────────────────────────────────────────────── - { key: 'helper-text', category: 'Inputs & Forms', element: }, - { key: 'label', category: 'Inputs & Forms', element: }, - { key: 'checkbox', category: 'Inputs & Forms', element: }, - { key: 'radio', category: 'Inputs & Forms', element: }, - { key: 'switch', category: 'Inputs & Forms', element: }, - { key: 'input', category: 'Inputs & Forms', element: }, - { key: 'textarea', category: 'Inputs & Forms', element: }, - { key: 'select', category: 'Inputs & Forms', element: }, - { key: 'checkbox-group', category: 'Inputs & Forms', element: }, - { key: 'radio-group', category: 'Inputs & Forms', element: }, - { key: 'date-picker', category: 'Inputs & Forms', element: }, - { key: 'form', category: 'Inputs & Forms', element: }, - { key: 'single-card-select', category: 'Inputs & Forms', element: }, - { key: 'multi-card-select', category: 'Inputs & Forms', element: }, - { key: 'card-options', category: 'Inputs & Forms', element: }, - { key: 'toggle-card', category: 'Inputs & Forms', element: }, - { key: 'rating', category: 'Inputs & Forms', element: }, - { key: 'slider', category: 'Inputs & Forms', element: }, - { key: 'range-slider', category: 'Inputs & Forms', element: }, - { key: 'number-input', category: 'Inputs & Forms', element: }, - { key: 'otp-input', category: 'Inputs & Forms', element: }, - { key: 'phone-input', category: 'Inputs & Forms', element: }, - { key: 'time-picker', category: 'Inputs & Forms', element: }, - { key: 'tag-input', category: 'Inputs & Forms', element: }, - { key: 'color-picker', category: 'Inputs & Forms', element: }, - { key: 'icon-picker', category: 'Inputs & Forms', element: }, - { key: 'extended-select', category: 'Inputs & Forms', element: }, - { key: 'dropdown', category: 'Inputs & Forms', element: }, - { key: 'form-input', category: 'Inputs & Forms', element: }, - { key: 'form-wizard', category: 'Inputs & Forms', element: }, - - // ── Buttons & Actions ──────────────────────────────────────────── - { key: 'button', category: 'Buttons & Actions', element: }, - { key: 'icon-button', category: 'Buttons & Actions', element: }, - { key: 'cool-button', category: 'Buttons & Actions', element: }, - { key: 'action-items', category: 'Buttons & Actions', element: }, - { key: 'action-header', category: 'Buttons & Actions', element: }, - { key: 'action-row-list', category: 'Buttons & Actions', element: }, - { key: 'danger-zone-actions', category: 'Buttons & Actions', element: }, - - // ── Layout & Structure ─────────────────────────────────────────── - { key: 'card', category: 'Layout & Structure', element: }, - { key: 'section-card', category: 'Layout & Structure', element: }, - { key: 'group', category: 'Layout & Structure', element: }, - { key: 'basic-layout', category: 'Layout & Structure', element: }, - { key: 'scroll-area', category: 'Layout & Structure', element: }, - { key: 'stepper', category: 'Layout & Structure', element: }, - { key: 'dashboard-card', category: 'Layout & Structure', element: }, - { key: 'resizable-panel', category: 'Layout & Structure', element: }, - { key: 'dashboard-layout', category: 'Layout & Structure', element: }, - - // ── Navigation ─────────────────────────────────────────────────── - { key: 'breadcrumb', category: 'Navigation', element: }, - { key: 'vertical-item-list', category: 'Navigation', element: }, - { key: 'social-links', category: 'Navigation', element: }, - { key: 'version-picker', category: 'Navigation', element: }, - { key: 'pagination', category: 'Navigation', element: }, - { key: 'cool-nav', category: 'Navigation', element: }, - { key: 'side-nav', category: 'Navigation', element: }, - { key: 'page-footer', category: 'Navigation', element: }, - { key: 'command-palette', category: 'Navigation', element: }, - - // ── Overlays & Feedback ────────────────────────────────────────── - { key: 'empty-state', category: 'Overlays & Feedback', element: }, - { key: 'spinner', category: 'Overlays & Feedback', element: }, - { key: 'skeleton', category: 'Overlays & Feedback', element: }, - { key: 'progress-bar', category: 'Overlays & Feedback', element: }, - { key: 'alert', category: 'Overlays & Feedback', element: }, - { key: 'announcement-bar', category: 'Overlays & Feedback', element: }, - { key: 'tooltip', category: 'Overlays & Feedback', element: }, - { key: 'popover', category: 'Overlays & Feedback', element: }, - { key: 'toast', category: 'Overlays & Feedback', element: }, - { key: 'context-menu', category: 'Overlays & Feedback', element: }, - { key: 'lightbox', category: 'Overlays & Feedback', element: }, - { key: 'drawer', category: 'Overlays & Feedback', element: }, - { key: 'modal', category: 'Overlays & Feedback', element: }, - - // ── Data Display ───────────────────────────────────────────────── - { key: 'pipeline', category: 'Data Display', element: }, - { key: 'tab-sections', category: 'Data Display', element: }, - { key: 'accordion', category: 'Data Display', element: }, - { key: 'faq-list', category: 'Data Display', element: }, - { key: 'feature-matrix', category: 'Data Display', element: }, - { key: 'compatibility-matrix', category: 'Data Display', element: }, - { key: 'good-first-issues', category: 'Data Display', element: }, - { key: 'changelog', category: 'Data Display', element: }, - { key: 'timeline', category: 'Data Display', element: }, - { key: 'roadmap', category: 'Data Display', element: }, - { key: 'build', category: 'Data Display', element: }, - { key: 'infinite-scroll', category: 'Data Display', element: }, - { key: 'virtual-list', category: 'Data Display', element: }, - { key: 'tree-view', category: 'Data Display', element: }, - { key: 'table', category: 'Data Display', element: }, - { key: 'advanced-table', category: 'Data Display', element: }, - - // ── Charts & Metrics ───────────────────────────────────────────── - { key: 'badge-row', category: 'Charts & Metrics', element: }, - { key: 'stat-card', category: 'Charts & Metrics', element: }, - { key: 'metric-grid', category: 'Charts & Metrics', element: }, - { key: 'bundle-bar', category: 'Charts & Metrics', element: }, - { key: 'usage-summary-panel', category: 'Charts & Metrics', element: }, - { key: 'download-stats', category: 'Charts & Metrics', element: }, - { key: 'benchmark-chart', category: 'Charts & Metrics', element: }, - { key: 'chart', category: 'Charts & Metrics', element: }, - - // ── Media & Files ──────────────────────────────────────────────── - { key: 'code-label-cell', category: 'Media & Files', element: }, - { key: 'simple-file', category: 'Media & Files', element: }, - { key: 'asset-row', category: 'Media & Files', element: }, - { key: 'queued-file', category: 'Media & Files', element: }, - { key: 'file', category: 'Media & Files', element: }, - { key: 'file-tags', category: 'Media & Files', element: }, - { key: 'file-dropzone', category: 'Media & Files', element: }, - { key: 'image', category: 'Media & Files', element: }, - { key: 'video-embed', category: 'Media & Files', element: }, - { key: 'image-crop', category: 'Media & Files', element: }, - { key: 'asset-bundle', category: 'Media & Files', element: }, - { key: 'cdn-map', category: 'Media & Files', element: }, - - // ── Identity & People ──────────────────────────────────────────── - { key: 'entity-cell', category: 'Identity & People', element: }, - { key: 'avatar', category: 'Identity & People', element: }, - { key: 'team-list', category: 'Identity & People', element: }, - { key: 'user-panel', category: 'Identity & People', element: }, - { key: 'contributor-wall', category: 'Identity & People', element: }, - { key: 'maintainer-card', category: 'Identity & People', element: }, - { key: 'entity-profile-card', category: 'Identity & People', element: }, - { key: 'linked-providers-card', category: 'Identity & People', element: }, - { key: 'login', category: 'Identity & People', element: }, - - // ── Marketing & Landing ────────────────────────────────────────── - { key: 'callout-quote', category: 'Marketing & Landing', element: }, - { key: 'feature-card', category: 'Marketing & Landing', element: }, - { key: 'banner', category: 'Marketing & Landing', element: }, - { key: 'logo-cloud', category: 'Marketing & Landing', element: }, - { key: 'sponsor-wall', category: 'Marketing & Landing', element: }, - { key: 'github-stars-card', category: 'Marketing & Landing', element: }, - { key: 'comparator', category: 'Marketing & Landing', element: }, - { key: 'plugin-grid', category: 'Marketing & Landing', element: }, - { key: 'ecosystem-map', category: 'Marketing & Landing', element: }, - { key: 'community-links', category: 'Marketing & Landing', element: }, - { key: 'testimonial-carousel', category: 'Marketing & Landing', element: }, - { key: 'carousel', category: 'Marketing & Landing', element: }, - { key: 'rich-page-header', category: 'Marketing & Landing', element: }, - { key: 'newsletter-signup', category: 'Marketing & Landing', element: }, - { key: 'early-signup-form', category: 'Marketing & Landing', element: }, - { key: 'quick-start', category: 'Marketing & Landing', element: }, - { key: 'welcome-guide', category: 'Marketing & Landing', element: }, - { key: 'pinned-feature-showcase', category: 'Marketing & Landing', element: }, - { key: 'pricing-card', category: 'Marketing & Landing', element: }, - { key: 'hero', category: 'Marketing & Landing', element: }, - - // ── Code & Docs ────────────────────────────────────────────────── - { key: 'terminal-window', category: 'Code & Docs', element: }, - { key: 'install-tabs', category: 'Code & Docs', element: }, - { key: 'code-snippet', category: 'Code & Docs', element: }, - { key: 'code-with-output', category: 'Code & Docs', element: }, - { key: 'diff-viewer', category: 'Code & Docs', element: }, - { key: 'config-preview', category: 'Code & Docs', element: }, - { key: 'cookbook-grid', category: 'Code & Docs', element: }, - { key: 'api-reference-table', category: 'Code & Docs', element: }, - { key: 'command-reference', category: 'Code & Docs', element: }, - { key: 'migration-guide', category: 'Code & Docs', element: }, - { key: 'json-schema-def', category: 'Code & Docs', element: }, - { key: 'markdown-editor', category: 'Code & Docs', element: }, - { key: 'json-editor', category: 'Code & Docs', element: }, - { key: 'bitmap-font-generator', category: 'Code & Docs', element: }, - { key: 'normal-map-generator', category: 'Code & Docs', element: }, - { key: 'physics-editor', category: 'Code & Docs', element: }, - { key: 'node-editor', category: 'Code & Docs', element: }, - { key: 'audio-mixer', category: 'Code & Docs', element: }, - - // ── Game Jam / Arcade ──────────────────────────────────────────── - { key: 'section-flag', category: 'Game Jam / Arcade', element: }, - { key: 'pulse-indicator', category: 'Game Jam / Arcade', element: }, - { key: 'rank-cell', category: 'Game Jam / Arcade', element: }, - { key: 'leaderboard-trend', category: 'Game Jam / Arcade', element: }, - { key: 'stamp', category: 'Game Jam / Arcade', element: }, - { key: 'marquee', category: 'Game Jam / Arcade', element: }, - { key: 'hero-stats-bar', category: 'Game Jam / Arcade', element: }, - { key: 'countdown-timer', category: 'Game Jam / Arcade', element: }, - { key: 'sprint-chain', category: 'Game Jam / Arcade', element: }, - { key: 'state-machine', category: 'Game Jam / Arcade', element: }, - { key: 'chip-group', category: 'Game Jam / Arcade', element: }, - { key: 'tier-ladder', category: 'Game Jam / Arcade', element: }, - { key: 'scoring-rules', category: 'Game Jam / Arcade', element: }, - { key: 'brief-card', category: 'Game Jam / Arcade', element: }, - { key: 'phase-grid', category: 'Game Jam / Arcade', element: }, - { key: 'cycle-wheel', category: 'Game Jam / Arcade', element: }, - { key: 'live-feed', category: 'Game Jam / Arcade', element: }, - { key: 'leaderboard', category: 'Game Jam / Arcade', element: }, - { key: 'game-showcase-card', category: 'Game Jam / Arcade', element: }, -] diff --git a/examples/src/routes.tsx b/examples/src/routes.tsx index 81b38b56..25e9f10b 100644 --- a/examples/src/routes.tsx +++ b/examples/src/routes.tsx @@ -5,15 +5,13 @@ import { Skills } from './pages/Skills' import { BasePage } from './pages/BasePage' import { LoggingPage } from './pages/LoggingPage' import { SerializerPage } from './pages/SerializerPage' -import { ReactComponentsPage } from './pages/ReactComponentsPage' -import { GameComponentsPage } from './pages/GameComponentsPage' +import { WebComponentsPage } from './pages/WebComponentsPage' import { PhaserPlusPage } from './pages/PhaserPlusPage' import { NodePage } from './pages/NodePage' import { baseExamples } from './base/index' import { loggingExamples } from './logging/index' import { serializerExamples } from './serializer/index' -import { examples as reactComponentExamples } from './react-components/index' -import { gameComponentExamples } from './game-components/index' +import { webComponentExamples } from './web-components/index' import { phaserExamples } from './phaser-plus/index' import { nodeExamples } from './node/index' @@ -70,28 +68,6 @@ export const packageRoutes: PackageRoute[] = [ element: e.element, })), }, - { - key: 'react-components', - basePath: '/react-components', - indexLabel: 'All Components', - page: , - examples: reactComponentExamples.map((e) => ({ - key: e.key, - title: formatLabel(e.key), - element: e.element, - })), - }, - { - key: 'game-components', - basePath: '/game-components', - indexLabel: 'All Game Components', - page: , - examples: gameComponentExamples.map((e) => ({ - key: e.key, - title: formatLabel(e.key), - element: e.element, - })), - }, { key: 'phaser-plus', basePath: '/phaser-plus', @@ -105,6 +81,17 @@ export const packageRoutes: PackageRoute[] = [ extraHeader: {e.sceneFile}, })), }, + { + key: 'web-components', + basePath: '/web-components', + indexLabel: 'All Web Components', + page: , + examples: webComponentExamples.map((e) => ({ + key: e.key, + title: formatLabel(e.key), + element: e.element, + })), + }, { key: 'node', basePath: '/node', diff --git a/examples/src/serializer/_demo/SerializerDemo.tsx b/examples/src/serializer/_demo/SerializerDemo.tsx index 4bb38489..50c10134 100644 --- a/examples/src/serializer/_demo/SerializerDemo.tsx +++ b/examples/src/serializer/_demo/SerializerDemo.tsx @@ -1,5 +1,4 @@ import { ReactNode } from 'react' -import { Button, Card, CodeSnippet, Heading, Text } from '@toolcase/react-components' export type LogEntry = { time: string; text: string } @@ -36,11 +35,15 @@ export type SerializerDemoProps = { } export const SerializerDemoCard = ({ title, description, code, onRun, logs }: SerializerDemoProps) => ( - - {title} - {description} - - - - +
+
+

{title}

+

{description}

+ + + +
+
) diff --git a/examples/src/style.css b/examples/src/style.css index a2939841..62bbdee9 100644 --- a/examples/src/style.css +++ b/examples/src/style.css @@ -1,5 +1,5 @@ /* @toolcase examples — site shell, ported from @toolcase website */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,500;1,400&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); :root { --bg: oklch(0.985 0.003 95); @@ -84,20 +84,23 @@ code, pre, .mono { font-family: var(--font-mono); } background: var(--accent); display: inline-block; } -.nav { +/* Scoped to the site header — `.nav` is also the Bootstrap-compatible class the + tc-nav web component uses, so unscoped rules here leak onto every nav demo + (light hover/active fills made tc-nav text unreadable, esp. in the dungeon theme). */ +.site-header .nav { display: flex; gap: 4px; align-items: center; } -.nav a { +.site-header .nav a { padding: 6px 12px; font-size: 14px; color: var(--fg-2); border-radius: var(--radius); font-weight: 500; } -.nav a:hover { color: var(--fg); background: var(--bg-2); } -.nav a.active { color: var(--fg); background: var(--bg-3); } +.site-header .nav a:hover { color: var(--fg); background: var(--bg-2); } +.site-header .nav a.active { color: var(--fg); background: var(--bg-3); } .header-spacer { flex: 1; } .header-meta { display: flex; @@ -204,6 +207,13 @@ code, pre, .mono { font-family: var(--font-mono); } font-size: 12px; color: var(--fg-3); } +.section-subtitle { + margin: -8px 0 20px; + max-width: 60ch; + font-size: 14px; + line-height: 1.5; + color: var(--fg-3); +} /* Library cards grid */ .lib-grid { @@ -676,7 +686,7 @@ code, pre, .mono { font-family: var(--font-mono); } .example__canvas { min-height: calc(100vh - 130px); } -/* react-components theme toggle */ +/* per-package theme toggle (web-components default / dungeon) */ .example__theme { font-family: var(--font-mono); font-size: 11px; @@ -696,9 +706,28 @@ code, pre, .mono { font-family: var(--font-mono); } color: var(--fg-2); cursor: pointer; } -/* dark theme wrapper fills the canvas so the demo sits on dark ground */ -.example__canvas > .theme--dark { +/* web-components theme wrapper: fill the canvas and paint the active theme's + surface/text so dark + accent themes get a proper themed ground */ +.example__canvas > .wc-theme-scope { min-height: calc(100vh - 130px); + background: var(--tc-surface); + color: var(--tc-text); +} +/* dungeon theme: paint the canvas with the fantasy torchlit backdrop instead of + the flat surface fill, so demos sit on proper dungeon ground */ +.example__canvas > .wc-theme-scope[data-tc-theme='dungeon'] { + background: radial-gradient(120% 90% at 50% 0%, #1f180e 0%, #0a0604 80%); + padding: 24px; +} +/* aurora theme: deep-slate canvas lit by the ambient mint glow field so demos + sit on the proper production-AI ground */ +.example__canvas > .wc-theme-scope[data-tc-theme='aurora'] { + background: + radial-gradient(820px 520px at 78% -8%, rgba(47, 230, 173, 0.1), transparent 60%), + radial-gradient(700px 600px at 8% 12%, rgba(36, 90, 120, 0.14), transparent 62%), + radial-gradient(900px 700px at 50% 120%, rgba(47, 230, 173, 0.1), transparent 55%), + #07090d; + padding: 24px; } /* Phaser canvas wrapper */ @@ -753,8 +782,8 @@ code, pre, .mono { font-family: var(--font-mono); } .example__bar-inner { padding: 10px 20px; } } -/* Custom scrollbar — site-level chrome. Moved out of @toolcase/react-components - (a component library should not own global scrollbar styling; improvements/01 R6). +/* Custom scrollbar — site-level chrome owned by the examples site itself + (a component library should not own global scrollbar styling). Thumb is square per the no-border-radius design rule. */ ::-webkit-scrollbar { width: 12px; diff --git a/examples/src/versions.ts b/examples/src/versions.ts index 6bfe00fb..e0009720 100644 --- a/examples/src/versions.ts +++ b/examples/src/versions.ts @@ -4,8 +4,7 @@ import { version as baseVersion } from '../../base/package.json' import { version as loggingVersion } from '../../logging/package.json' import { version as serializerVersion } from '../../serializer/package.json' -import { version as reactComponentsVersion } from '../../react-components/package.json' -import { version as gameComponentsVersion } from '../../game-components/package.json' +import { version as webComponentsVersion } from '../../web-components/package.json' import { version as phaserPlusVersion } from '../../phaser-plus/package.json' import { version as nodeVersion } from '../../node/package.json' @@ -13,8 +12,7 @@ export const versions = { base: baseVersion, logging: loggingVersion, serializer: serializerVersion, - 'react-components': reactComponentsVersion, - 'game-components': gameComponentsVersion, + 'web-components': webComponentsVersion, 'phaser-plus': phaserPlusVersion, node: nodeVersion, } as const diff --git a/examples/src/vite-env.d.ts b/examples/src/vite-env.d.ts index a6970e30..0c92dc39 100644 --- a/examples/src/vite-env.d.ts +++ b/examples/src/vite-env.d.ts @@ -1,5 +1,5 @@ /// declare module '*.css' -declare module '@toolcase/react-components/style.css' +declare module '@toolcase/web-components/style.css' declare module 'bootstrap-icons/font/bootstrap-icons.css' diff --git a/examples/src/web-components/AbilityCardDemo.tsx b/examples/src/web-components/AbilityCardDemo.tsx new file mode 100644 index 00000000..1887d540 --- /dev/null +++ b/examples/src/web-components/AbilityCardDemo.tsx @@ -0,0 +1,118 @@ +import React from 'react' + +const AbilityCardDemo: React.FC = () => { + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+
+ {/* @ts-ignore */} + +
+
+ {/* @ts-ignore */} + +
+
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default AbilityCardDemo diff --git a/examples/src/web-components/AccordionDemo.tsx b/examples/src/web-components/AccordionDemo.tsx new file mode 100644 index 00000000..673f1e2f --- /dev/null +++ b/examples/src/web-components/AccordionDemo.tsx @@ -0,0 +1,98 @@ +import React from 'react' + +const AccordionDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + {/* @ts-ignore */} + +

+ This is the first item's content. It starts expanded because + of the open attribute. +

+ {/* @ts-ignore */} +
+ {/* @ts-ignore */} + +

+ This is the second item's content. Opening this will close + the first one. +

+ {/* @ts-ignore */} +
+ {/* @ts-ignore */} + +

This is the third item's content.

+ {/* @ts-ignore */} +
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +

+ Flush accordions remove outer borders and rounded corners. +

+ {/* @ts-ignore */} +
+ {/* @ts-ignore */} + +

Each item still collapses individually.

+ {/* @ts-ignore */} +
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +

+ This panel can stay open while others are also opened. +

+ {/* @ts-ignore */} +
+ {/* @ts-ignore */} + +

+ Both items start expanded and opening/closing one doesn't + affect the other. +

+ {/* @ts-ignore */} +
+ {/* @ts-ignore */} + +

This one starts collapsed.

+ {/* @ts-ignore */} +
+ {/* @ts-ignore */} +
+
+
+
+
+
+
+) + +export default AccordionDemo diff --git a/examples/src/web-components/ActionHeaderDemo.tsx b/examples/src/web-components/ActionHeaderDemo.tsx new file mode 100644 index 00000000..040e2ced --- /dev/null +++ b/examples/src/web-components/ActionHeaderDemo.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useRef } from 'react' +import type { ActionHeaderAction } from '@toolcase/web-components' + +const BASIC_ACTIONS: ActionHeaderAction[] = [ + { key: 'edit', label: 'Edit', icon: 'Pencil' }, + { key: 'delete', label: 'Delete', variant: 'danger' }, +] + +const ICON_ONLY_ACTIONS: ActionHeaderAction[] = [ + { key: 'download', icon: 'Download' }, + { key: 'refresh', icon: 'RefreshCw' }, + { key: 'settings', icon: 'Settings' }, +] + +const DISABLED_ACTIONS: ActionHeaderAction[] = [ + { key: 'save', label: 'Save', icon: 'Save' }, + { key: 'discard', label: 'Discard', disabled: true }, +] + +const ActionHeaderDemo: React.FC = () => { + const basicRef = useRef(null) + const iconRef = useRef(null) + const disabledRef = useRef(null) + const allDisabledRef = useRef(null) + + useEffect(() => { + if (basicRef.current) { + const el = basicRef.current as HTMLElement & { + actions: ActionHeaderAction[] + onExec: (key: string) => void + } + el.actions = BASIC_ACTIONS + el.onExec = (key: string) => alert(`Exec: ${key}`) + } + if (iconRef.current) { + const el = iconRef.current as HTMLElement & { actions: ActionHeaderAction[] } + el.actions = ICON_ONLY_ACTIONS + } + if (disabledRef.current) { + const el = disabledRef.current as HTMLElement & { actions: ActionHeaderAction[] } + el.actions = DISABLED_ACTIONS + } + if (allDisabledRef.current) { + const el = allDisabledRef.current as HTMLElement & { + actions: ActionHeaderAction[] + disabled: boolean + } + el.actions = BASIC_ACTIONS + el.disabled = true + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + Users + + + + + {/* @ts-ignore */} + + Documents + + + + + {/* @ts-ignore */} + + Draft + + + + + {/* @ts-ignore */} + + Read-only section + + +
+
+
+
+
+ ) +} + +export default ActionHeaderDemo diff --git a/examples/src/web-components/ActionItemsDemo.tsx b/examples/src/web-components/ActionItemsDemo.tsx new file mode 100644 index 00000000..ed5fcf3e --- /dev/null +++ b/examples/src/web-components/ActionItemsDemo.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useRef } from 'react' + +const ActionItemsDemo: React.FC = () => { + const basicRef = useRef(null) + const dangerRef = useRef(null) + + useEffect(() => { + if (basicRef.current) { + basicRef.current.items = [ + { key: 'view', label: 'View details' }, + { key: 'edit', label: 'Edit' }, + { key: 'duplicate', label: 'Duplicate' }, + { key: 'divider-1', label: '', divider: true }, + { key: 'archive', label: 'Archive', disabled: true }, + { key: 'delete', label: 'Delete', danger: true }, + ] + + const handler = (e: Event) => { + const key = (e as CustomEvent<{ key: string }>).detail.key + console.log('tc-action-click', key) + } + basicRef.current.addEventListener('tc-action-click', handler) + return () => basicRef.current?.removeEventListener('tc-action-click', handler) + } + }, []) + + useEffect(() => { + if (dangerRef.current) { + dangerRef.current.items = [ + { key: 'download', label: 'Download', icon: 'Download' }, + { key: 'share', label: 'Share', icon: 'Share2' }, + { key: 'divider-2', label: '', divider: true }, + { key: 'trash', label: 'Delete', icon: 'Trash2', danger: true }, + ] + dangerRef.current.onActionClick = (key: string) => { + console.log('onActionClick callback', key) + } + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +

+ Open the browser console to see tc-action-click events. +

+ {/* @ts-ignore */} + +
+ + + {/* @ts-ignore */} + + + + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default ActionItemsDemo diff --git a/examples/src/web-components/ActionRowListDemo.tsx b/examples/src/web-components/ActionRowListDemo.tsx new file mode 100644 index 00000000..a29d721a --- /dev/null +++ b/examples/src/web-components/ActionRowListDemo.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useRef } from 'react' + +const ActionRowListDemo: React.FC = () => { + const basicRef = useRef(null) + const outlineRef = useRef(null) + const customIconRef = useRef(null) + const noIconRef = useRef(null) + + useEffect(() => { + if (!basicRef.current) return + basicRef.current.actions = [ + { + key: 'profile', + title: 'Profile settings', + description: 'Manage your account and preferences', + label: 'Edit', + }, + { + key: 'billing', + title: 'Billing', + description: 'Update payment methods and plan', + label: 'Manage', + }, + { + key: 'security', + title: 'Security', + description: 'Two-factor authentication and sessions', + label: 'Configure', + }, + { + key: 'api', + title: 'API access', + description: 'Generate and revoke API tokens', + label: 'View tokens', + disabled: true, + }, + ] + const handler = (e: Event) => { + const key = (e as CustomEvent<{ key: string }>).detail.key + console.log('tc-action-click', key) + } + basicRef.current.addEventListener('tc-action-click', handler) + return () => basicRef.current?.removeEventListener('tc-action-click', handler) + }, []) + + useEffect(() => { + if (!outlineRef.current) return + outlineRef.current.actions = [ + { + key: 'export', + title: 'Export data', + description: 'Download all records as CSV', + label: 'Export', + icon: 'Download', + }, + { + key: 'import', + title: 'Import data', + description: 'Upload a CSV to bulk-create records', + label: 'Import', + icon: 'Upload', + }, + { + key: 'delete', + title: 'Delete account', + description: 'Permanently removes all data', + label: 'Delete', + variant: 'danger', + }, + ] + outlineRef.current.onActionClick = (key: string) => { + console.log('onActionClick callback', key) + } + }, []) + + useEffect(() => { + if (!customIconRef.current) return + customIconRef.current.actions = [ + { + key: 'notify', + title: 'Notifications', + description: 'Email and push settings', + label: 'Configure', + }, + { + key: 'theme', + title: 'Appearance', + description: 'Light/dark mode and fonts', + label: 'Customize', + }, + ] + }, []) + + useEffect(() => { + if (!noIconRef.current) return + noIconRef.current.actions = [ + { + key: 'downloads', + title: 'Downloads', + description: 'Files and exports', + label: 'View', + }, + { + key: 'logs', + title: 'Activity logs', + description: 'Recent account activity', + label: 'Browse', + }, + ] + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +

+ Open the browser console to see tc-action-click events. +

+ {/* @ts-ignore */} + +
+ + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default ActionRowListDemo diff --git a/examples/src/web-components/ActivityCardDemo.tsx b/examples/src/web-components/ActivityCardDemo.tsx new file mode 100644 index 00000000..fa6005ca --- /dev/null +++ b/examples/src/web-components/ActivityCardDemo.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useRef } from 'react' + +const SAMPLE_ACTIVITIES = [ + { + id: '1', + icon: 'GitCommit', + title: 'Pushed 3 commits to main', + description: 'feat: add activity card web component', + timestamp: '2m ago', + }, + { + id: '2', + icon: 'CheckCircle', + title: 'CI pipeline passed', + description: 'All 42 tests passed in 18s', + timestamp: '4m ago', + }, + { + id: '3', + icon: 'MessageSquare', + title: 'New comment on PR #84', + description: 'Looks good, approving', + timestamp: '12m ago', + }, + { + id: '4', + icon: 'UserPlus', + title: 'Alice Martin joined the team', + timestamp: '1h ago', + }, +] + +const SAMPLE_SHORT = [ + { id: '1', icon: 'Upload', title: 'Deployed v2.4.1 to production', timestamp: 'just now' }, + { + id: '2', + icon: 'AlertTriangle', + title: 'Error rate spike detected', + description: '3 errors in the last minute', + timestamp: '30s ago', + }, +] + +const ActivityCardDemo: React.FC = () => { + const loadedRef = useRef(null) + const noTitleRef = useRef(null) + const shortRef = useRef(null) + + useEffect(() => { + if (loadedRef.current) { + loadedRef.current.activities = SAMPLE_ACTIVITIES + } + if (noTitleRef.current) { + noTitleRef.current.activities = SAMPLE_ACTIVITIES + } + if (shortRef.current) { + shortRef.current.activities = SAMPLE_SHORT + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default ActivityCardDemo diff --git a/examples/src/web-components/AdvancedTableDemo.tsx b/examples/src/web-components/AdvancedTableDemo.tsx new file mode 100644 index 00000000..c621ecd2 --- /dev/null +++ b/examples/src/web-components/AdvancedTableDemo.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useRef, useState } from 'react' +import type { + AdvancedTableColumn, + AdvancedTableFilter, + AdvancedTableSort, +} from '@toolcase/web-components' + +interface Row { + name: string + role: string + team: string + commits: number + active: boolean +} + +const DATA: Row[] = [ + { name: 'Alice Chen', role: 'Maintainer', team: 'Core', commits: 842, active: true }, + { name: 'Bob Müller', role: 'Contributor', team: 'Docs', commits: 311, active: true }, + { name: 'Carol Diaz', role: 'Contributor', team: 'Core', commits: 596, active: false }, + { name: 'Dave Kim', role: 'Reviewer', team: 'Infra', commits: 128, active: true }, + { name: 'Eve Okafor', role: 'Contributor', team: 'Infra', commits: 477, active: true }, + { name: 'Frank Li', role: 'Maintainer', team: 'Core', commits: 705, active: false }, + { name: 'Grace Park', role: 'Reviewer', team: 'Docs', commits: 233, active: true }, + { name: 'Heidi Novak', role: 'Contributor', team: 'Infra', commits: 388, active: true }, + { name: 'Ivan Petrov', role: 'Contributor', team: 'Docs', commits: 162, active: false }, + { name: 'Judy Wong', role: 'Maintainer', team: 'Core', commits: 914, active: true }, + { name: 'Karl Brandt', role: 'Reviewer', team: 'Infra', commits: 274, active: true }, + { name: 'Lena Roth', role: 'Contributor', team: 'Docs', commits: 519, active: false }, +] + +const COLUMNS: AdvancedTableColumn[] = [ + { key: 'name', label: 'Name' }, + { key: 'role', label: 'Role' }, + { key: 'team', label: 'Team' }, + { key: 'commits', label: 'Commits', align: 'right' }, +] + +// Built-in toolbar filters (text + selects). The "active-only" toggle lives in a +// custom toolbar above the table (tc-switch isn't a built-in filter type) but is +// wired into the same filterValues state, so every control filters the one dataset. +const FILTERS: AdvancedTableFilter[] = [ + { key: 'name', label: 'Search name', type: 'text', placeholder: 'Type to filter…' }, + { + key: 'role', + label: 'Role', + type: 'select', + placeholder: 'All roles', + options: [ + { value: 'Maintainer', label: 'Maintainer' }, + { value: 'Contributor', label: 'Contributor' }, + { value: 'Reviewer', label: 'Reviewer' }, + ], + }, + { + key: 'team', + label: 'Team', + type: 'select', + placeholder: 'All teams', + options: [ + { value: 'Core', label: 'Core' }, + { value: 'Docs', label: 'Docs' }, + { value: 'Infra', label: 'Infra' }, + ], + }, + { + key: 'minCommits', + label: 'Min commits', + type: 'select', + placeholder: 'Any', + options: [ + { value: '250', label: '250+' }, + { value: '500', label: '500+' }, + { value: '750', label: '750+' }, + ], + }, +] + +const SORTABLE = ['name', 'role', 'team', 'commits'] +const LIMIT = 5 + +const INITIAL_FILTERS: Record = { + name: '', + role: '', + team: '', + minCommits: '', + activeOnly: false, +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function rowsHtml(rows: Row[]): string { + if (rows.length === 0) { + return `No contributors match the filters` + } + return rows + .map( + (r) => + `${esc(r.name)}${esc(r.role)}${esc(r.team)}${r.commits}`, + ) + .join('') +} + +const AdvancedTableDemo: React.FC = () => { + const tableRef = useRef(null) + const switchRef = useRef(null) + const loadingRef = useRef(null) + + const [filterValues, setFilterValues] = useState>(INITIAL_FILTERS) + const [sort, setSort] = useState({ + column: 'commits', + direction: 'desc', + }) + const [offset, setOffset] = useState(0) + + // Wire events once. The element drives its own internal sort/offset and emits + // the CustomEvents; we mirror them into React state and push the values back. + useEffect(() => { + const el = tableRef.current + if (!el) return + + const onFilter = (e: Event) => { + const { key, value } = (e as CustomEvent).detail + setFilterValues((prev) => ({ ...prev, [key]: value })) + setOffset(0) // a changed filter resets to the first page + } + const onSortChange = (e: Event) => { + const { column, direction } = (e as CustomEvent).detail + setSort(column ? { column, direction } : null) + } + const onPage = (e: Event) => setOffset((e as CustomEvent).detail.offset) + + el.addEventListener('tc-filter-change', onFilter) + el.addEventListener('tc-sort-change', onSortChange) + el.addEventListener('tc-page-change', onPage) + return () => { + el.removeEventListener('tc-filter-change', onFilter) + el.removeEventListener('tc-sort-change', onSortChange) + el.removeEventListener('tc-page-change', onPage) + } + }, []) + + // The tc-switch toggle ("Active only") is a separate control above the table, + // wired into the same filterValues state — it filters the same dataset. + useEffect(() => { + const el = switchRef.current + if (!el) return + const onToggle = (e: Event) => { + const value = !!(e as CustomEvent).detail.value + setFilterValues((prev) => ({ ...prev, activeOnly: value })) + setOffset(0) + } + el.addEventListener('tc-change', onToggle) + return () => el.removeEventListener('tc-change', onToggle) + }, []) + + // Re-derive the filtered / sorted / paginated view and push everything to the + // element. Body rows are set imperatively into the projected . + useEffect(() => { + const el = tableRef.current + if (!el) return + + const search = String(filterValues.name ?? '').toLowerCase() + const role = String(filterValues.role ?? '') + const team = String(filterValues.team ?? '') + const minCommits = parseInt(String(filterValues.minCommits ?? ''), 10) || 0 + const activeOnly = !!filterValues.activeOnly + + let view = DATA.filter( + (r) => + (!search || r.name.toLowerCase().includes(search)) && + (!role || r.role === role) && + (!team || r.team === team) && + r.commits >= minCommits && + (!activeOnly || r.active), + ) + + if (sort) { + const dir = sort.direction === 'asc' ? 1 : -1 + view = [...view].sort((a, b) => { + const av = (a as any)[sort.column] + const bv = (b as any)[sort.column] + if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir + return String(av).localeCompare(String(bv)) * dir + }) + } + + const total = view.length + const page = view.slice(offset, offset + LIMIT) + + el.columns = COLUMNS + el.filters = FILTERS + el.sortableColumns = SORTABLE + el.filterValues = filterValues + el.sort = sort + el.limit = LIMIT + el.offset = offset + el.total = total + + const body = el.querySelector('.tc-advanced-table-body') + if (body) body.innerHTML = rowsHtml(page) + }, [filterValues, sort, offset]) + + // Static loading-overlay example. + useEffect(() => { + const el = loadingRef.current + if (!el) return + el.columns = COLUMNS + el.sortableColumns = SORTABLE + el.sort = { column: 'commits', direction: 'desc' } + el.limit = LIMIT + el.offset = 0 + el.total = DATA.length + el.loading = true + const body = el.querySelector('.tc-advanced-table-body') + if (body) body.innerHTML = rowsHtml(DATA.slice(0, LIMIT)) + }, []) + + const matchCount = DATA.filter( + (r) => + (!filterValues.name || + r.name.toLowerCase().includes(String(filterValues.name).toLowerCase())) && + (!filterValues.role || r.role === filterValues.role) && + (!filterValues.team || r.team === filterValues.team) && + r.commits >= (parseInt(String(filterValues.minCommits ?? ''), 10) || 0) && + (!filterValues.activeOnly || r.active), + ).length + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* Extra control above the table: a tc-switch wired into + the same filterValues state as the built-in filters. */} +
+ {/* @ts-ignore */} + + + {matchCount} of {DATA.length} match + +
+ {/* @ts-ignore */} + +
+ + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default AdvancedTableDemo diff --git a/examples/src/web-components/AlertDemo.tsx b/examples/src/web-components/AlertDemo.tsx new file mode 100644 index 00000000..a2861b92 --- /dev/null +++ b/examples/src/web-components/AlertDemo.tsx @@ -0,0 +1,76 @@ +import React from 'react' + +const AlertDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + A primary alert — check it out! + + {/* @ts-ignore */} + + A secondary alert — check it out! + + {/* @ts-ignore */} + + A success alert — check it out! + + {/* @ts-ignore */} + A danger alert — check it out! + {/* @ts-ignore */} + + A warning alert — check it out! + + {/* @ts-ignore */} + An info alert — check it out! + {/* @ts-ignore */} + A light alert — check it out! + {/* @ts-ignore */} + A dark alert — check it out! +
+
+ + +
+ {/* @ts-ignore */} + + Well done! You successfully read this important + alert message. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Warning! Better check yourself, you're not + looking too good. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Oh snap! Change a few things up and try + submitting again. + {/* @ts-ignore */} + +
+
+
+
+
+
+
+) + +export default AlertDemo diff --git a/examples/src/web-components/AmmoCounterDemo.tsx b/examples/src/web-components/AmmoCounterDemo.tsx new file mode 100644 index 00000000..ee678da6 --- /dev/null +++ b/examples/src/web-components/AmmoCounterDemo.tsx @@ -0,0 +1,86 @@ +import React from 'react' + +const AmmoCounterDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+) + +export default AmmoCounterDemo diff --git a/examples/src/web-components/AnchorDemo.tsx b/examples/src/web-components/AnchorDemo.tsx new file mode 100644 index 00000000..4666f606 --- /dev/null +++ b/examples/src/web-components/AnchorDemo.tsx @@ -0,0 +1,118 @@ +import React from 'react' + +// A relatively-positioned stage so tc-anchor children pin to its corners/edges. +const stageStyle: React.CSSProperties = { + position: 'relative', + height: '220px', + border: '1px solid var(--tc-border)', + background: 'var(--tc-surface-muted)', +} + +const chipStyle: React.CSSProperties = { + display: 'inline-block', + padding: '0.25rem 0.5rem', + fontFamily: 'var(--tc-font-mono)', + fontSize: '0.6875rem', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: '#fff', + background: 'var(--tc-app-accent)', + border: '1px solid var(--tc-app-accent)', + whiteSpace: 'nowrap', +} + +const Chip = ({ label }: { label: string }) => {label} + +const AnchorDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + +
+
+ + +
+ {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + +
+
+ + +
+ {/* @ts-ignore */} + + + +
+
+
+
+
+
+
+) + +export default AnchorDemo diff --git a/examples/src/web-components/ApiReferenceTableDemo.tsx b/examples/src/web-components/ApiReferenceTableDemo.tsx new file mode 100644 index 00000000..ae24611c --- /dev/null +++ b/examples/src/web-components/ApiReferenceTableDemo.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useRef } from 'react' + +const groupedData = [ + { + category: 'Lifecycle', + items: [ + { + name: 'connectedCallback', + signature: '(): void', + returns: 'void', + description: + 'Called when the element is connected to the document. Initialises the component on first call.', + }, + { + name: 'disconnectedCallback', + signature: '(): void', + returns: 'void', + description: 'Called when the element is removed from the document.', + }, + { + name: 'attributeChangedCallback', + signature: '(name: string, oldVal: string | null, newVal: string | null): void', + returns: 'void', + description: 'Called when an observed attribute changes value.', + deprecated: + 'Use the JS property setters instead of observed attributes for data binding.', + }, + ], + }, + { + category: 'Properties', + items: [ + { + name: 'groups', + signature: 'ApiReferenceGroup[]', + returns: 'ApiReferenceGroup[]', + description: + 'Array of API groups. Each group has a category label and an items array. Setting re-renders.', + }, + { + name: 'items', + signature: 'ApiItem[]', + returns: 'ApiItem[]', + description: + 'Flat list of API items rendered as a single ungrouped section when groups is empty.', + }, + { + name: 'title', + signature: 'string | null', + returns: 'string | null', + description: + 'Optional title displayed as a header above the table. Can also be supplied as slotted children.', + deprecated: true, + }, + ], + }, +] + +const flatItems = [ + { + name: 'register', + signature: '(): void', + returns: 'void', + description: 'Registers all tc-* custom elements via customElements.define.', + }, + { + name: 'icon', + signature: '(svg: string, className?: string): string', + returns: 'string', + description: 'Strips fixed width/height from a Lucide SVG string and marks it aria-hidden.', + }, + { + name: 'deregister', + signature: '(): void', + returns: 'void', + description: 'Removes all registered tc-* custom elements.', + deprecated: + 'Custom elements cannot be unregistered after definition. This method is a no-op.', + }, +] + +const ApiReferenceTableDemo: React.FC = () => { + const groupedRef = useRef(null) + const flatRef = useRef(null) + const titledRef = useRef(null) + const slottedRef = useRef(null) + + useEffect(() => { + if (groupedRef.current) groupedRef.current.groups = groupedData + if (flatRef.current) flatRef.current.items = flatItems + if (titledRef.current) titledRef.current.groups = groupedData + if (slottedRef.current) slottedRef.current.items = flatItems + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + tc-api-reference-table{' '} + + — slotted title + + + + +
+
+
+
+
+ ) +} + +export default ApiReferenceTableDemo diff --git a/examples/src/web-components/AreaChartDemo.tsx b/examples/src/web-components/AreaChartDemo.tsx new file mode 100644 index 00000000..09ea8b84 --- /dev/null +++ b/examples/src/web-components/AreaChartDemo.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useRef, useState } from 'react' + +const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'] + +const traffic = [ + { + name: 'Visitors', + points: months.map((m, i) => ({ + x: m, + y: [1200, 1900, 1700, 2400, 2200, 3100, 3600, 4200][i], + })), + }, + { + name: 'Signups', + points: months.map((m, i) => ({ x: m, y: [180, 240, 210, 360, 340, 520, 610, 740][i] })), + }, +] + +const revenue = [ + { + name: 'Pro', + points: months.map((m, i) => ({ x: m, y: [4, 6, 7, 9, 11, 13, 15, 18][i] * 1000 })), + }, + { + name: 'Team', + points: months.map((m, i) => ({ x: m, y: [2, 3, 3, 5, 6, 8, 9, 11][i] * 1000 })), + }, + { + name: 'Enterprise', + points: months.map((m, i) => ({ x: m, y: [1, 1, 2, 2, 3, 4, 5, 7][i] * 1000 })), + }, +] + +const yMoney = (v: number): string => { + if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M` + if (v >= 1_000) return `$${(v / 1_000).toFixed(1)}k` + return `$${Math.round(v)}` +} +const yCompact = (v: number): string => { + if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k` + return String(Math.round(v)) +} + +const AreaChartDemo: React.FC = () => { + const basicRef = useRef(null) + const stackedRef = useRef(null) + const noGridRef = useRef(null) + const [hover, setHover] = useState(null) + + useEffect(() => { + if (basicRef.current) { + basicRef.current.series = traffic + basicRef.current.yFormatter = yCompact + basicRef.current.xFormatter = (v: string | number) => String(v) + } + if (stackedRef.current) { + stackedRef.current.series = revenue + stackedRef.current.yFormatter = yMoney + } + if (noGridRef.current) { + noGridRef.current.series = traffic + noGridRef.current.yFormatter = yCompact + } + + const el = basicRef.current + if (el) { + const handler = (e: any) => + setHover(`${e.detail.series.name}: ${e.detail.point.x} = ${e.detail.point.y}`) + el.addEventListener('tc-point-hover', handler) + return () => el.removeEventListener('tc-point-hover', handler) + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + +

+ Hover a point — last: {hover ?? 'none'} +

+
+ + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default AreaChartDemo diff --git a/examples/src/web-components/ArtboardBackdropDemo.tsx b/examples/src/web-components/ArtboardBackdropDemo.tsx new file mode 100644 index 00000000..ebde58b3 --- /dev/null +++ b/examples/src/web-components/ArtboardBackdropDemo.tsx @@ -0,0 +1,85 @@ +import React from 'react' + +const headingStyle: React.CSSProperties = { + margin: '0 0 0.25rem', + fontSize: '1rem', + fontWeight: 600, +} + +const bodyStyle: React.CSSProperties = { + margin: 0, + opacity: 0.85, + fontSize: '0.875rem', +} + +const Sample = ({ title }: { title: string }) => ( + <> +

{title}

+

+ A full-bleed backdrop surface for staging artwork, previews, or hero content. +

+ +) + +const ArtboardBackdropDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + +
+
+ + +
+ {(['none', 'sm', 'md', 'lg', 'xl'] as const).map((pad) => ( + // @ts-ignore + + + {/* @ts-ignore */} + + ))} +
+
+ + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + +
+
+
+
+
+) + +export default ArtboardBackdropDemo diff --git a/examples/src/web-components/AspectRatioBoxDemo.tsx b/examples/src/web-components/AspectRatioBoxDemo.tsx new file mode 100644 index 00000000..27006810 --- /dev/null +++ b/examples/src/web-components/AspectRatioBoxDemo.tsx @@ -0,0 +1,88 @@ +import React from 'react' + +const Fill = ({ label }: { label: string }) => ( +
+ {label} +
+) + +const AspectRatioBoxDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + Sample + {/* @ts-ignore */} + + +
+
+
+
+
+) + +export default AspectRatioBoxDemo diff --git a/examples/src/web-components/AssetBundleDemo.tsx b/examples/src/web-components/AssetBundleDemo.tsx new file mode 100644 index 00000000..4f121971 --- /dev/null +++ b/examples/src/web-components/AssetBundleDemo.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useRef, useState } from 'react' + +const MENU_ITEMS = [ + { key: 'edit', icon: 'Pencil', label: 'Edit Bundle' }, + { key: 'duplicate', icon: 'Copy', label: 'Duplicate' }, + { key: 'build', icon: 'Play', label: 'Build Now' }, + { key: 'export', icon: 'Download', label: 'Export Config' }, + { key: 'divider-1', label: '', divider: true }, + { key: 'delete', icon: 'Trash2', label: 'Delete', danger: true }, +] + +const AssetBundleDemo: React.FC = () => { + const fullRef = useRef(null) + const godotRef = useRef(null) + const [menuKey, setMenuKey] = useState(null) + const [advancedOpen, setAdvancedOpen] = useState(null) + const [buildTag, setBuildTag] = useState(null) + + useEffect(() => { + const el = fullRef.current + if (!el) return + el.includedTags = ['hud', 'buttons', 'icons'] + el.excludedTags = ['debug', 'placeholder'] + el.counts = { textures: 124, fonts: 8, configs: 3 } + el.advanced = { + compress: true, + powerOfTwo: true, + trim: false, + padding: 2, + algorithm: 'maxrects', + } + el.menuItems = MENU_ITEMS + + const onMenu = (e: CustomEvent) => setMenuKey(e.detail.key) + const onToggle = (e: CustomEvent) => setAdvancedOpen(e.detail.open) + const onTag = (e: CustomEvent) => setBuildTag(e.detail.tag) + el.addEventListener('tc-menu-click', onMenu) + el.addEventListener('tc-advanced-toggle', onToggle) + el.addEventListener('tc-build-tag-change', onTag) + return () => { + el.removeEventListener('tc-menu-click', onMenu) + el.removeEventListener('tc-advanced-toggle', onToggle) + el.removeEventListener('tc-build-tag-change', onTag) + } + }, []) + + useEffect(() => { + const el = godotRef.current + if (!el) return + el.includedTags = ['hero', 'npc', 'enemy'] + el.excludedTags = ['wip'] + el.counts = { textures: 89, animations: 34, sounds: 12 } + el.advanced = { compress: false, powerOfTwo: true, trim: true, padding: 0 } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+

+ Toggle Advanced and switch the build-tag chips — events + are logged in the card title above (tc-menu-click,{' '} + tc-advanced-toggle,{' '} + tc-build-tag-change). +

+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default AssetBundleDemo diff --git a/examples/src/web-components/AssetRowDemo.tsx b/examples/src/web-components/AssetRowDemo.tsx new file mode 100644 index 00000000..a16fc86c --- /dev/null +++ b/examples/src/web-components/AssetRowDemo.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useRef } from 'react' + +const AssetRowDemo: React.FC = () => { + const withTagsRef = useRef(null) + const multiTagsRef = useRef(null) + + useEffect(() => { + if (!withTagsRef.current) return + withTagsRef.current.tags = ['v1.2.0', 'stable'] + }, []) + + useEffect(() => { + if (!multiTagsRef.current) return + multiTagsRef.current.tags = ['ts', 'esm', 'minified'] + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + + + + + custom-name.json + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default AssetRowDemo diff --git a/examples/src/web-components/AssetRowListDemo.tsx b/examples/src/web-components/AssetRowListDemo.tsx new file mode 100644 index 00000000..2a923746 --- /dev/null +++ b/examples/src/web-components/AssetRowListDemo.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useRef } from 'react' + +const AssetRowListDemo: React.FC = () => { + const taggedRef = useRef(null) + const pkgRef = useRef(null) + + useEffect(() => { + if (!taggedRef.current) return + taggedRef.current.tags = ['v2.1.0', 'stable'] + }, []) + + useEffect(() => { + if (!pkgRef.current) return + pkgRef.current.tags = ['ts', 'esm', 'minified'] + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default AssetRowListDemo diff --git a/examples/src/web-components/AudioMixerDemo.tsx b/examples/src/web-components/AudioMixerDemo.tsx new file mode 100644 index 00000000..9446b661 --- /dev/null +++ b/examples/src/web-components/AudioMixerDemo.tsx @@ -0,0 +1,234 @@ +import React, { useEffect, useRef, useState } from 'react' + +interface Effect { + id: string + type: string + params?: Record +} +interface Clip { + id: string + startMs: number + lengthMs: number + label?: string + effects?: Effect[] +} +interface Track { + id: string + name: string + muted?: boolean + solo?: boolean + volume?: number + clips: Clip[] +} +interface MixerDoc { + durationMs: number + tracks: Track[] +} +interface Selection { + trackId?: string | null + clipId?: string | null +} + +const INITIAL_DOC: MixerDoc = { + durationMs: 32000, + tracks: [ + { + id: 'drums', + name: 'Drums', + volume: 0.9, + clips: [ + { + id: 'd1', + startMs: 0, + lengthMs: 8000, + label: 'Loop A', + effects: [{ id: 'fx0', type: 'eq' }], + }, + { id: 'd2', startMs: 12000, lengthMs: 10000, label: 'Loop B' }, + ], + }, + { + id: 'bass', + name: 'Bass', + volume: 0.75, + muted: true, + clips: [{ id: 'b1', startMs: 2000, lengthMs: 18000, label: 'Sub bass' }], + }, + { + id: 'lead', + name: 'Lead Synth', + volume: 0.6, + solo: true, + clips: [ + { id: 'l1', startMs: 6000, lengthMs: 6000, label: 'Riff' }, + { id: 'l2', startMs: 16000, lengthMs: 12000, label: 'Hook' }, + ], + }, + ], +} + +const mapTrack = (doc: MixerDoc, trackId: string, fn: (t: Track) => Track): MixerDoc => ({ + ...doc, + tracks: doc.tracks.map((t) => (t.id === trackId ? fn(t) : t)), +}) + +const mapClip = (doc: MixerDoc, clipId: string, fn: (c: Clip) => Clip): MixerDoc => ({ + ...doc, + tracks: doc.tracks.map((t) => ({ + ...t, + clips: t.clips.map((c) => (c.id === clipId ? fn(c) : c)), + })), +}) + +const findTrackOfClip = (doc: MixerDoc, clipId: string): Track | undefined => + doc.tracks.find((t) => t.clips.some((c) => c.id === clipId)) + +const AudioMixerDemo: React.FC = () => { + const ref = useRef(null) + const [doc, setDoc] = useState(INITIAL_DOC) + const [selection, setSelection] = useState({ trackId: 'drums', clipId: 'd1' }) + const [currentMs, setCurrentMs] = useState(9000) + + // Keep the element's JS properties in sync with React state (controlled). + useEffect(() => { + const el = ref.current + if (!el) return + el.doc = doc + el.selection = selection + }, [doc, selection]) + + // Wire the focused CustomEvents to local-state reducers. + useEffect(() => { + const el = ref.current + if (!el) return + + const onSeek = (e: Event) => setCurrentMs((e as CustomEvent).detail.ms) + const onSelect = (e: Event) => { + const { trackId, clipId } = (e as CustomEvent).detail + setSelection({ trackId, clipId }) + } + const onClipMove = (e: Event) => { + const { clipId, startMs } = (e as CustomEvent).detail + setDoc((d) => mapClip(d, clipId, (c) => ({ ...c, startMs }))) + } + const onClipResize = (e: Event) => { + const { clipId, lengthMs } = (e as CustomEvent).detail + setDoc((d) => mapClip(d, clipId, (c) => ({ ...c, lengthMs }))) + } + const onMute = (e: Event) => { + const { trackId, muted } = (e as CustomEvent).detail + setDoc((d) => mapTrack(d, trackId, (t) => ({ ...t, muted }))) + } + const onSolo = (e: Event) => { + const { trackId, solo } = (e as CustomEvent).detail + setDoc((d) => mapTrack(d, trackId, (t) => ({ ...t, solo }))) + } + const onVolume = (e: Event) => { + const { trackId, volume } = (e as CustomEvent).detail + setDoc((d) => mapTrack(d, trackId, (t) => ({ ...t, volume }))) + } + const onEffectAdd = (e: Event) => { + const { clipId, effect } = (e as CustomEvent).detail + setDoc((d) => + mapClip(d, clipId, (c) => ({ ...c, effects: [...(c.effects ?? []), effect] })), + ) + } + const onEffectRemove = (e: Event) => { + const { clipId, effect } = (e as CustomEvent).detail + setDoc((d) => + mapClip(d, clipId, (c) => ({ + ...c, + effects: (c.effects ?? []).filter((fx) => fx.id !== effect.id), + })), + ) + } + + el.addEventListener('tc-seek', onSeek) + el.addEventListener('tc-select', onSelect) + el.addEventListener('tc-clip-move', onClipMove) + el.addEventListener('tc-clip-resize', onClipResize) + el.addEventListener('tc-track-mute', onMute) + el.addEventListener('tc-track-solo', onSolo) + el.addEventListener('tc-volume-change', onVolume) + el.addEventListener('tc-effect-add', onEffectAdd) + el.addEventListener('tc-effect-remove', onEffectRemove) + return () => { + el.removeEventListener('tc-seek', onSeek) + el.removeEventListener('tc-select', onSelect) + el.removeEventListener('tc-clip-move', onClipMove) + el.removeEventListener('tc-clip-resize', onClipResize) + el.removeEventListener('tc-track-mute', onMute) + el.removeEventListener('tc-track-solo', onSolo) + el.removeEventListener('tc-volume-change', onVolume) + el.removeEventListener('tc-effect-add', onEffectAdd) + el.removeEventListener('tc-effect-remove', onEffectRemove) + } + }, []) + + const selectedTrack = doc.tracks.find((t) => t.id === selection.trackId) + const selectedClip = + selection.clipId != null + ? findTrackOfClip(doc, selection.clipId)?.clips.find((c) => c.id === selection.clipId) + : undefined + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + +
+ Playhead: {Math.round(currentMs)} ms · Selected:{' '} + {selectedClip + ? `clip "${selectedClip.label ?? selectedClip.id}"` + : selectedTrack + ? `track "${selectedTrack.name}"` + : 'none'} + . Drag a clip to move it, drag its edges to resize, click the + ruler to seek, and add effects in the inspector. +
+
+ + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +// A static, frozen instance to show the disabled treatment. +const DisabledMixer: React.FC = () => { + const ref = useRef(null) + useEffect(() => { + const el = ref.current + if (!el) return + el.doc = INITIAL_DOC + el.selection = { trackId: 'lead', clipId: 'l1' } + }, []) + // @ts-ignore + return +} + +export default AudioMixerDemo diff --git a/examples/src/web-components/AvatarDemo.tsx b/examples/src/web-components/AvatarDemo.tsx new file mode 100644 index 00000000..b1a6c361 --- /dev/null +++ b/examples/src/web-components/AvatarDemo.tsx @@ -0,0 +1,165 @@ +import React from 'react' + +const VARIANTS = ['primary', 'secondary', 'success', 'danger', 'warning', 'info'] as const +const STATUSES = ['online', 'offline', 'busy', 'away'] as const + +const AvatarDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {VARIANTS.map((v) => ( + + {/* @ts-ignore */} + + + ))} +
+
+ + +
+ {STATUSES.map((s) => ( + + {/* @ts-ignore */} + + + ))} +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* Broken image + name → falls back to initials */} + {/* @ts-ignore */} + + {/* Broken image, no name → falls back to placeholder glyph */} + {/* @ts-ignore */} + + {/* Broken image + status → fallback preserves the status dot */} + {/* @ts-ignore */} + +
+
+
+
+
+
+
+) + +export default AvatarDemo diff --git a/examples/src/web-components/BadgeDemo.tsx b/examples/src/web-components/BadgeDemo.tsx new file mode 100644 index 00000000..66460164 --- /dev/null +++ b/examples/src/web-components/BadgeDemo.tsx @@ -0,0 +1,79 @@ +import React from 'react' + +const BadgeDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + 42 + {/* @ts-ignore */} + + New + + {/* @ts-ignore */} + 99+ +
+
+
+
+
+
+
+) + +export default BadgeDemo diff --git a/examples/src/web-components/BadgeRowDemo.tsx b/examples/src/web-components/BadgeRowDemo.tsx new file mode 100644 index 00000000..0983cf61 --- /dev/null +++ b/examples/src/web-components/BadgeRowDemo.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useRef } from 'react' + +const BadgeRowDemo: React.FC = () => { + const labelOnlyRef = useRef(null) + const mdRef = useRef(null) + const smRef = useRef(null) + const variantRef = useRef(null) + const colorRef = useRef(null) + + useEffect(() => { + if (labelOnlyRef.current) { + labelOnlyRef.current.badges = [ + { label: 'Region' }, + { label: 'Production' }, + { label: 'v2' }, + ] + } + }, []) + + useEffect(() => { + if (mdRef.current) { + mdRef.current.badges = [ + { label: 'env', value: 'production' }, + { label: 'region', value: 'eu-west-1' }, + { label: 'replicas', value: 3 }, + { label: 'uptime', value: '99.97%' }, + ] + } + }, []) + + useEffect(() => { + if (smRef.current) { + smRef.current.badges = [ + { label: 'env', value: 'staging' }, + { label: 'region', value: 'us-east-1' }, + { label: 'replicas', value: 2 }, + ] + } + }, []) + + useEffect(() => { + if (variantRef.current) { + variantRef.current.badges = [ + { label: 'status', value: 'healthy', variant: 'success' }, + { label: 'status', value: 'degraded', variant: 'warning' }, + { label: 'status', value: 'down', variant: 'danger' }, + { label: 'tier', value: 'info', variant: 'info' }, + { label: 'type', value: 'primary', variant: 'primary' }, + ] + } + }, []) + + useEffect(() => { + if (colorRef.current) { + colorRef.current.badges = [ + { label: 'team', value: 'frontend', color: '#6366f1' }, + { label: 'team', value: 'backend', color: '#0ea5e9' }, + { label: 'team', value: 'infra', color: '#f59e0b' }, + ] + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default BadgeRowDemo diff --git a/examples/src/web-components/BannerDemo.tsx b/examples/src/web-components/BannerDemo.tsx new file mode 100644 index 00000000..69ea3ab4 --- /dev/null +++ b/examples/src/web-components/BannerDemo.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useRef, useState } from 'react' + +const BannerDemo: React.FC = () => { + const dismissibleRef = useRef(null) + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + const el = dismissibleRef.current + if (!el) return + const handler = () => { + setDismissed(true) + console.log('tc-dismiss fired') + } + el.addEventListener('tc-dismiss', handler) + return () => el.removeEventListener('tc-dismiss', handler) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + New documentation is available — check it out. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Maintenance window scheduled for Sunday 02:00 UTC. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Your changes have been saved successfully. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Failed to connect to the server. Please try again. + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + You have 3 unread notifications. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Deployment to production completed in 42 seconds. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Your session will expire in 5 minutes. + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + A new version of the dashboard is available. + {/* @ts-ignore */} + + Update now + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Your trial expires in 3 days. Upgrade to keep your data. + {/* @ts-ignore */} + + Upgrade plan + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +

+ {dismissed + ? 'Banner dismissed — tc-dismiss fired (check console).' + : 'Click × to dismiss. The tc-dismiss event is logged to the console.'} +

+ {/* @ts-ignore */} + + Scheduled downtime on Friday 23:00 UTC. Save your work. + {/* @ts-ignore */} + +
+ + +

+ Closing this banner writes "dismissed" to{' '} + localStorage["tc-demo-banner"]. It stays hidden on + reload. Clear the key in DevTools to see it again. +

+ {/* @ts-ignore */} + + Toolcase Web Components v3 is now available. Enjoy the new + components! + {/* @ts-ignore */} + +
+ + +
+ {/* @ts-ignore */} + + Toolcase v3 is here — explore the new component library. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + New docs are available — uses the legacy persist-dismiss-key + attribute. + {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default BannerDemo diff --git a/examples/src/web-components/BarChartDemo.tsx b/examples/src/web-components/BarChartDemo.tsx new file mode 100644 index 00000000..1bbd6d4e --- /dev/null +++ b/examples/src/web-components/BarChartDemo.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useRef, useState } from 'react' + +const traffic = [ + { label: 'Jan', value: 1200 }, + { label: 'Feb', value: 1900 }, + { label: 'Mar', value: 1700 }, + { label: 'Apr', value: 2400 }, + { label: 'May', value: 2200 }, + { label: 'Jun', value: 3100 }, + { label: 'Jul', value: 3600 }, + { label: 'Aug', value: 4200 }, +] + +const languages = [ + { label: 'TypeScript', value: 48200 }, + { label: 'Rust', value: 31400 }, + { label: 'Go', value: 22800 }, + { label: 'Python', value: 18600 }, + { label: 'Shell', value: 5400 }, +] + +const status = [ + { label: 'Passing', value: 142, color: 'var(--tc-success)' }, + { label: 'Flaky', value: 18, color: 'var(--tc-warning)' }, + { label: 'Failing', value: 6, color: 'var(--tc-danger)' }, +] + +const yCompact = (v: number): string => { + if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k` + return String(Math.round(v)) +} +const yPlain = (v: number): string => String(Math.round(v)) + +const BarChartDemo: React.FC = () => { + const verticalRef = useRef(null) + const horizontalRef = useRef(null) + const statusRef = useRef(null) + const [clicked, setClicked] = useState(null) + + useEffect(() => { + if (verticalRef.current) { + verticalRef.current.data = traffic + verticalRef.current.yFormatter = yCompact + } + if (horizontalRef.current) { + horizontalRef.current.data = languages + horizontalRef.current.yFormatter = yCompact + } + if (statusRef.current) { + statusRef.current.data = status + statusRef.current.yFormatter = yPlain + } + + const el = verticalRef.current + if (el) { + const handler = (e: any) => + setClicked( + `${e.detail.item.label} = ${e.detail.item.value} (index ${e.detail.index})`, + ) + el.addEventListener('tc-bar-click', handler) + return () => el.removeEventListener('tc-bar-click', handler) + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + +

+ Last clicked bar: {clicked ?? 'none'} +

+
+ + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default BarChartDemo diff --git a/examples/src/web-components/BasicCardDemo.tsx b/examples/src/web-components/BasicCardDemo.tsx new file mode 100644 index 00000000..3c85fca2 --- /dev/null +++ b/examples/src/web-components/BasicCardDemo.tsx @@ -0,0 +1,55 @@ +import React from 'react' + +const BasicCardDemo: React.FC = () => { + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default BasicCardDemo diff --git a/examples/src/web-components/BasicLayoutDemo.tsx b/examples/src/web-components/BasicLayoutDemo.tsx new file mode 100644 index 00000000..483669d2 --- /dev/null +++ b/examples/src/web-components/BasicLayoutDemo.tsx @@ -0,0 +1,79 @@ +import React from 'react' + +const BasicLayoutDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
Main content area
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ + Toolcase + + + v2.0 + +
+
+ Main content with rich brand slot +
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ Full-height main area, no header +
+ {/* @ts-ignore */} +
+
+
+
+
+
+
+
+) + +export default BasicLayoutDemo diff --git a/examples/src/web-components/BattlePassDemo.tsx b/examples/src/web-components/BattlePassDemo.tsx new file mode 100644 index 00000000..5dec013b --- /dev/null +++ b/examples/src/web-components/BattlePassDemo.tsx @@ -0,0 +1,99 @@ +import React, { useEffect, useRef, useState } from 'react' + +const tiers = Array.from({ length: 8 }, (_, i) => { + const level = i + 1 + return { + level, + xpRequired: 1000, + free: { label: `${level * 100} XP`, icon: 'star', claimed: level < 3 }, + premium: { + label: level % 3 === 0 ? 'Skin' : 'Gold', + icon: level % 3 === 0 ? 'shirt' : 'coins', + claimed: level < 2, + }, + } +}) + +const freeOnly = Array.from({ length: 5 }, (_, i) => { + const level = i + 1 + return { + level, + xpRequired: 800, + free: { + label: level % 2 === 0 ? 'Crate' : `${level * 50} XP`, + icon: level % 2 === 0 ? 'package' : 'star', + claimed: level < 2, + }, + // a couple of tiers have no free reward to show the empty-slot placeholder + premium: level % 2 === 0 ? { label: 'Emote', icon: 'smile' } : undefined, + } +}) + +const BattlePassDemo: React.FC = () => { + const premiumRef = useRef(null) + const freeRef = useRef(null) + const [last, setLast] = useState('—') + + useEffect(() => { + if (premiumRef.current) premiumRef.current.tiers = tiers + if (freeRef.current) freeRef.current.tiers = freeOnly + }, []) + + useEffect(() => { + const el = premiumRef.current + if (!el) return + const handler = (e: any) => setLast(`Claimed L${e.detail.level} · ${e.detail.track}`) + el.addEventListener('tc-claim', handler) + return () => el.removeEventListener('tc-claim', handler) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default BattlePassDemo diff --git a/examples/src/web-components/BenchmarkChartDemo.tsx b/examples/src/web-components/BenchmarkChartDemo.tsx new file mode 100644 index 00000000..296f4b0d --- /dev/null +++ b/examples/src/web-components/BenchmarkChartDemo.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useRef, useState } from 'react' + +const throughputBars = [ + { label: '@your/server', value: 124000, unit: 'req/s' }, + { label: 'fastify', value: 102000, unit: 'req/s' }, + { label: 'koa', value: 78000, unit: 'req/s' }, + { label: 'express', value: 41000, unit: 'req/s', baseline: true }, +] + +const latencyBars = [ + { label: '@your/server', value: 8, unit: 'ms' }, + { label: 'fastify', value: 11, unit: 'ms' }, + { label: 'express', value: 24, unit: 'ms' }, +] + +const logBars = [ + { label: 'in-memory', value: 12 }, + { label: 'redis', value: 480 }, + { label: 'postgres', value: 5200 }, + { label: 's3', value: 140000 }, +] + +const coloredBars = [ + { label: 'JavaScript', value: 4820, color: '#f59e0b' }, + { label: 'TypeScript', value: 3140, color: '#6366f1' }, + { label: 'Python', value: 2200, color: '#22c55e' }, + { label: 'Rust', value: 980, color: '#ef4444' }, +] + +const BenchmarkChartDemo: React.FC = () => { + const throughputRef = useRef(null) + const latencyRef = useRef(null) + const logRef = useRef(null) + const interactiveRef = useRef(null) + const slotRef = useRef(null) + const [clicked, setClicked] = useState(null) + + useEffect(() => { + if (throughputRef.current) throughputRef.current.bars = throughputBars + if (latencyRef.current) latencyRef.current.bars = latencyBars + if (logRef.current) logRef.current.bars = logBars + if (slotRef.current) slotRef.current.bars = throughputBars + + const el = interactiveRef.current + if (el) { + el.bars = coloredBars + const handler = (e: any) => setClicked(`${e.detail.bar.label} (#${e.detail.index})`) + el.addEventListener('tc-bar-click', handler) + return () => el.removeEventListener('tc-bar-click', handler) + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +

+ Interactive — last clicked bar:{' '} + {clicked ?? 'none'} +

+
+ + + {/* @ts-ignore */} + + + Requests per second v2.4 + + + +
+
+
+
+
+ ) +} + +export default BenchmarkChartDemo diff --git a/examples/src/web-components/BitmapFontGeneratorDemo.tsx b/examples/src/web-components/BitmapFontGeneratorDemo.tsx new file mode 100644 index 00000000..1df809ca --- /dev/null +++ b/examples/src/web-components/BitmapFontGeneratorDemo.tsx @@ -0,0 +1,91 @@ +import React, { useEffect, useRef, useState } from 'react' + +const BitmapFontGeneratorDemo: React.FC = () => { + const styledRef = useRef(null) + const [lastOutput, setLastOutput] = useState(null) + + useEffect(() => { + const el = styledRef.current + if (!el) return + + // Complex object props are set as JS properties (not attributes). + el.fill = { + type: 'gradient', + gradientType: 'linear', + gradientColors: ['#ff6b6b', '#ffd93d'], + gradientAngle: 90, + } + el.border = { color: '#1e293b', thickness: 3, align: 'center' } + el.dropShadow = { color: '#000000', size: 4, offsetX: 2, offsetY: 2, blur: 4 } + el.glow = { color: '#22d3ee', size: 6 } + + const onGenerate = (e: Event) => { + const detail = (e as CustomEvent).detail + setLastOutput( + `${detail.format.toUpperCase()} · ${detail.width}×${detail.height}px · ${detail.glyphs.length} glyphs`, + ) + } + el.addEventListener('tc-generate', onGenerate) + return () => el.removeEventListener('tc-generate', onGenerate) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {lastOutput && ( +

+ Last tc-generate: {lastOutput} +

+ )} +
+ + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default BitmapFontGeneratorDemo diff --git a/examples/src/web-components/BlurOverlayDemo.tsx b/examples/src/web-components/BlurOverlayDemo.tsx new file mode 100644 index 00000000..1740fbe8 --- /dev/null +++ b/examples/src/web-components/BlurOverlayDemo.tsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react' + +type Variant = { + key: string + title: string + description: string + blurAmount?: string + background?: string +} + +const VARIANTS: Variant[] = [ + { + key: 'default', + title: 'Default scrim', + description: + 'Defaults — 8px blur over a slate-ink wash. Click the backdrop (or Resume) to dismiss.', + }, + { + key: 'strong', + title: 'blur-amount="14px"', + description: 'A heavier blur for a fully-obscured pause screen.', + blurAmount: '14px', + }, + { + key: 'tinted', + title: 'background="rgba(2, 6, 23, 0.7)"', + description: 'A darker custom scrim — both knobs are free-form CSS values.', + blurAmount: '6px', + background: 'rgba(2, 6, 23, 0.7)', + }, +] + +const panelStyle: React.CSSProperties = { + background: 'var(--tc-surface)', + border: '1px solid var(--tc-border)', + boxShadow: 'var(--tc-shadow-lg)', + padding: '1.5rem', + maxWidth: '360px', + width: '100%', + textAlign: 'center', +} + +const BlurOverlayDemo: React.FC = () => { + const [openKey, setOpenKey] = useState(null) + const active = VARIANTS.find((v) => v.key === openKey) ?? null + + return ( +
+
+
+
+ + + Web Components + + + +
+ {VARIANTS.map((v) => ( + +

+ {v.description} +

+ +
+ ))} +
+
+
+
+ + {active && ( + // The consumer mounts/unmounts the passive scrim. Clicking the + // backdrop (the host itself) closes; the panel stops propagation. + /* @ts-ignore */ + setOpenKey(null)} + > +
e.stopPropagation()}> +

+ Paused +

+

+ The page behind this panel is blurred by the overlay. +

+ +
+ {/* @ts-ignore */} +
+ )} +
+ ) +} + +export default BlurOverlayDemo diff --git a/examples/src/web-components/BossBarDemo.tsx b/examples/src/web-components/BossBarDemo.tsx new file mode 100644 index 00000000..ba8ce96c --- /dev/null +++ b/examples/src/web-components/BossBarDemo.tsx @@ -0,0 +1,76 @@ +import React, { useEffect, useRef } from 'react' + +const BossBarDemo: React.FC = () => { + // phaseTicks is a JS-property array — React can't set it as an attribute, so + // reach for the element via a ref and assign it after mount. + const ticksRef = useRef(null) + useEffect(() => { + if (ticksRef.current) ticksRef.current.phaseTicks = [0.25, 0.5, 0.75] + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default BossBarDemo diff --git a/examples/src/web-components/BrandDemo.tsx b/examples/src/web-components/BrandDemo.tsx new file mode 100644 index 00000000..16d44024 --- /dev/null +++ b/examples/src/web-components/BrandDemo.tsx @@ -0,0 +1,99 @@ +import React from 'react' + +const BrandDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + ToolCase + + {/* @ts-ignore */} + + Tool + Case + alpha + +
+
+
+
+
+
+
+) + +export default BrandDemo diff --git a/examples/src/web-components/BreadcrumbDemo.tsx b/examples/src/web-components/BreadcrumbDemo.tsx new file mode 100644 index 00000000..abcd7e24 --- /dev/null +++ b/examples/src/web-components/BreadcrumbDemo.tsx @@ -0,0 +1,59 @@ +import React from 'react' + +const BreadcrumbDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + {/* @ts-ignore */} + Home + {/* @ts-ignore */} + Library + {/* @ts-ignore */} + Data + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Home + {/* @ts-ignore */} + Products + {/* @ts-ignore */} + Widget + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Home + {/* @ts-ignore */} + + +
+
+
+
+
+) + +export default BreadcrumbDemo diff --git a/examples/src/web-components/BriefCardDemo.tsx b/examples/src/web-components/BriefCardDemo.tsx new file mode 100644 index 00000000..b42ae548 --- /dev/null +++ b/examples/src/web-components/BriefCardDemo.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useRef, useState } from 'react' + +const BriefCardDemo: React.FC = () => { + const [lastClicked, setLastClicked] = useState(null) + const clickableRef = useRef(null) + + useEffect(() => { + const el = clickableRef.current + if (!el) return + const handler = (e: CustomEvent) => { + setLastClicked(e.detail.id ?? '(no id)') + } + el.addEventListener('tc-click', handler) + return () => el.removeEventListener('tc-click', handler) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + + + + Shard the users table + +
+
+ + +
+ {/* @ts-ignore */} + + {lastClicked && ( +

+ Last tc-click → id: {lastClicked} +

+ )} +
+
+
+
+
+
+
+ ) +} + +export default BriefCardDemo diff --git a/examples/src/web-components/BrightnessCalibrationDemo.tsx b/examples/src/web-components/BrightnessCalibrationDemo.tsx new file mode 100644 index 00000000..ae37597c --- /dev/null +++ b/examples/src/web-components/BrightnessCalibrationDemo.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useRef, useState } from 'react' + +const BrightnessCalibrationDemo: React.FC = () => { + const ref = useRef(null) + const [value, setValue] = useState(0.5) + + useEffect(() => { + const el = ref.current + if (!el) return + const handler = (e: Event) => setValue((e as CustomEvent).detail.value) + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+ Brightness: {Math.round(value * 100)}% +
+
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default BrightnessCalibrationDemo diff --git a/examples/src/web-components/BuffBarDemo.tsx b/examples/src/web-components/BuffBarDemo.tsx new file mode 100644 index 00000000..f9c446eb --- /dev/null +++ b/examples/src/web-components/BuffBarDemo.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useRef } from 'react' + +// buffs is a JS-property array (BuffEntry[]) — React can't set it as an +// attribute, so reach for each element via a ref and assign after mount. +const useBuffs = (buffs: any[]) => { + const ref = useRef(null) + useEffect(() => { + if (ref.current) ref.current.buffs = buffs + }, []) + return ref +} + +const BuffBarDemo: React.FC = () => { + const mixedRef = useBuffs([ + { id: 'haste', icon: 'Zap', name: 'Haste', remaining: 12, duration: 20 }, + { id: 'shield', icon: 'Shield', name: 'Barrier', remaining: 8, duration: 30 }, + { + id: 'regen', + icon: 'Heart', + name: 'Regeneration', + remaining: 45, + duration: 60, + stacks: 3, + }, + { id: 'poison', icon: 'Skull', name: 'Poison', remaining: 6, duration: 10, debuff: true }, + { + id: 'slow', + icon: 'Snail', + name: 'Slowed', + remaining: 4, + duration: 8, + debuff: true, + stacks: 2, + }, + ]) + + const longRef = useBuffs([ + { id: 'blessing', icon: 'Sparkles', name: 'Blessing', remaining: 185, duration: 300 }, + { id: 'focus', icon: 'Target', name: 'Focus', remaining: 92, duration: 120, stacks: 5 }, + ]) + + const debuffsRef = useBuffs([ + { id: 'burn', icon: 'Flame', name: 'Burning', remaining: 3, duration: 6, debuff: true }, + { id: 'curse', icon: 'Ghost', name: 'Cursed', remaining: 18, duration: 25, debuff: true }, + { + id: 'freeze', + icon: 'Snowflake', + name: 'Frozen', + remaining: 2, + duration: 4, + debuff: true, + }, + ]) + + const bigRef = useBuffs([ + { id: 'rage', icon: 'Swords', name: 'Berserk', remaining: 22, duration: 30 }, + { + id: 'guard', + icon: 'ShieldCheck', + name: 'Guarded', + remaining: 14, + duration: 30, + stacks: 2, + }, + { id: 'haste2', icon: 'Wind', name: 'Quickened', remaining: 9, duration: 20 }, + ]) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default BuffBarDemo diff --git a/examples/src/web-components/BuffIconDemo.tsx b/examples/src/web-components/BuffIconDemo.tsx new file mode 100644 index 00000000..8dfc39e2 --- /dev/null +++ b/examples/src/web-components/BuffIconDemo.tsx @@ -0,0 +1,104 @@ +import React from 'react' + +// tc-buff-icon is purely attribute-driven (no JS-property arrays, no events), +// so the demo authors the raw element directly — no ref bookkeeping needed. +const BuffIconDemo: React.FC = () => { + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default BuffIconDemo diff --git a/examples/src/web-components/BuildDemo.tsx b/examples/src/web-components/BuildDemo.tsx new file mode 100644 index 00000000..8ed1262a --- /dev/null +++ b/examples/src/web-components/BuildDemo.tsx @@ -0,0 +1,177 @@ +import React, { useEffect, useRef, useState } from 'react' + +const MENU_ITEMS = [ + { key: 'retry', label: 'Retry build' }, + { key: 'logs', label: 'View logs' }, + { key: 'divider-1', label: '', divider: true }, + { key: 'delete', label: 'Delete', danger: true }, +] + +const BuildDemo: React.FC = () => { + const menuRef = useRef(null) + const clickableRef = useRef(null) + const [menuKey, setMenuKey] = useState(null) + const [clickCount, setClickCount] = useState(0) + + useEffect(() => { + const el = menuRef.current + if (!el) return + el.menuItems = MENU_ITEMS + const handler = (e: CustomEvent) => setMenuKey(e.detail.key) + el.addEventListener('tc-menu-select', handler) + return () => el.removeEventListener('tc-menu-select', handler) + }, []) + + useEffect(() => { + const el = clickableRef.current + if (!el) return + el.onClick = () => setClickCount((n) => n + 1) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+ {menuKey && ( +

+ tc-menu-select fired with key:{' '} + {menuKey} +

+ )} +
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default BuildDemo diff --git a/examples/src/web-components/BundleBarDemo.tsx b/examples/src/web-components/BundleBarDemo.tsx new file mode 100644 index 00000000..51171c2e --- /dev/null +++ b/examples/src/web-components/BundleBarDemo.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useRef } from 'react' + +const BundleBarDemo: React.FC = () => { + const chipsRef = useRef(null) + const statusRef = useRef(null) + + useEffect(() => { + if (chipsRef.current) { + chipsRef.current.chips = [ + { label: 'main', value: '2.4 MB' }, + { label: 'vendor', value: '1.1 MB' }, + { label: 'lazy', value: '320 KB' }, + ] + } + }, []) + + useEffect(() => { + if (statusRef.current) { + statusRef.current.chips = [ + { label: 'passed', value: '142', color: '#16a34a' }, + { label: 'failed', value: '3', color: '#dc2626' }, + { label: 'skipped', value: '8' }, + ] + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + Custom name via slot + slot meta + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default BundleBarDemo diff --git a/examples/src/web-components/ButtonDemo.tsx b/examples/src/web-components/ButtonDemo.tsx new file mode 100644 index 00000000..dbddb181 --- /dev/null +++ b/examples/src/web-components/ButtonDemo.tsx @@ -0,0 +1,144 @@ +import React from 'react' + +const ButtonDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + Primary + {/* @ts-ignore */} + Secondary + {/* @ts-ignore */} + Success + {/* @ts-ignore */} + Danger + {/* @ts-ignore */} + Warning + {/* @ts-ignore */} + Info + {/* @ts-ignore */} + Light + {/* @ts-ignore */} + Dark +
+
+ + +
+ {/* @ts-ignore */} + + Primary + + {/* @ts-ignore */} + + Secondary + + {/* @ts-ignore */} + + Success + + {/* @ts-ignore */} + + Danger + + {/* @ts-ignore */} + + Warning + + {/* @ts-ignore */} + + Info + + {/* @ts-ignore */} + + Light + + {/* @ts-ignore */} + + Dark + +
+
+ + +
+ {/* @ts-ignore */} + + Large + + {/* @ts-ignore */} + Default + {/* @ts-ignore */} + + Small + +
+
+ + +
+ {/* @ts-ignore */} + + Saving… + + {/* @ts-ignore */} + + Loading… + + {/* @ts-ignore */} + + Processing… + +
+
+ + +
+ {/* @ts-ignore */} + + Disabled + + {/* @ts-ignore */} + + Disabled + +
+
+ + +
+ {/* @ts-ignore */} + + Link button + + {/* @ts-ignore */} + + Outline link + + {/* @ts-ignore */} + + Disabled link + +
+
+
+
+
+
+
+) + +export default ButtonDemo diff --git a/examples/src/web-components/ButtonGroupDemo.tsx b/examples/src/web-components/ButtonGroupDemo.tsx new file mode 100644 index 00000000..98d5f14b --- /dev/null +++ b/examples/src/web-components/ButtonGroupDemo.tsx @@ -0,0 +1,104 @@ +import React from 'react' + +const ButtonGroupDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + {/* @ts-ignore */} + Left + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Right + {/* @ts-ignore */} + + + + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + Left + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Right + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Left + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Right + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Left + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Right + {/* @ts-ignore */} + +
+
+ + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Top + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Bottom + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Delete + + {/* @ts-ignore */} + + Archive + + {/* @ts-ignore */} + + Save + + {/* @ts-ignore */} + + +
+
+
+
+
+) + +export default ButtonGroupDemo diff --git a/examples/src/web-components/CalloutQuoteDemo.tsx b/examples/src/web-components/CalloutQuoteDemo.tsx new file mode 100644 index 00000000..b1cb5f9c --- /dev/null +++ b/examples/src/web-components/CalloutQuoteDemo.tsx @@ -0,0 +1,63 @@ +import React from 'react' + +const CalloutQuoteDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + Simplicity is the ultimate sophistication. + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+) + +export default CalloutQuoteDemo diff --git a/examples/src/web-components/CardDemo.tsx b/examples/src/web-components/CardDemo.tsx new file mode 100644 index 00000000..0388b7cb --- /dev/null +++ b/examples/src/web-components/CardDemo.tsx @@ -0,0 +1,136 @@ +import React from 'react' + +const CardDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+
+ {/* @ts-ignore */} + +

+ Some quick example text to build on the card title and + make up the bulk of the card's content. +

+ + Go somewhere + + {/* @ts-ignore */} +
+
+
+
+ + +
+
+ {/* @ts-ignore */} + +

+ A card with an image at the top. The image uses{' '} + .card-img-top and rounds the upper corners. +

+ {/* @ts-ignore */} +
+
+
+
+ + +
+
+ {/* @ts-ignore */} + +

+ A card with an image at the bottom. The image uses{' '} + .card-img-bottom and rounds the lower + corners. +

+ {/* @ts-ignore */} +
+
+
+
+ + +
+
+ {/* @ts-ignore */} + + Featured +

+ With supporting text below as a natural lead-in to + additional content. +

+ + Go somewhere + + + + Last updated 3 mins ago + + + {/* @ts-ignore */} +
+
+
+
+ + +
+ {( + [ + 'primary', + 'secondary', + 'success', + 'danger', + 'warning', + 'info', + 'light', + 'dark', + ] as const + ).map((v) => ( +
+ {/* @ts-ignore */} + +

+ A {v} card using the{' '} + variant attribute. +

+ {/* @ts-ignore */} +
+
+ ))} +
+
+
+
+
+
+
+) + +export default CardDemo diff --git a/examples/src/web-components/CardOptionsDemo.tsx b/examples/src/web-components/CardOptionsDemo.tsx new file mode 100644 index 00000000..319b8b30 --- /dev/null +++ b/examples/src/web-components/CardOptionsDemo.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useRef, useState } from 'react' + +const CardOptionsDemo: React.FC = () => { + const iconRef = useRef(null) + const descRef = useRef(null) + const fourColRef = useRef(null) + + const [selectedPlan, setSelectedPlan] = useState('starter') + const [selectedEnv, setSelectedEnv] = useState('development') + + useEffect(() => { + const el = iconRef.current + if (!el) return + el.options = [ + { key: 'shield', label: 'Shield', icon: 'Shield' }, + { key: 'database', label: 'Database', icon: 'Database' }, + { key: 'cloud', label: 'Cloud', icon: 'Cloud' }, + ] + el.setAttribute('value', 'shield') + el.setAttribute('aria-label', 'Select a feature') + const handler = (e: Event) => { + const key = (e as CustomEvent<{ key: string }>).detail.key + console.log('tc-change', key) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + const el = descRef.current + if (!el) return + el.options = [ + { + key: 'starter', + label: 'Starter', + icon: 'Zap', + description: 'Up to 3 projects', + }, + { + key: 'pro', + label: 'Pro', + icon: 'Star', + description: 'Unlimited projects', + }, + ] + el.setAttribute('value', 'starter') + el.setAttribute('aria-label', 'Select a plan') + el.setAttribute('columns', '2') + const handler = (e: Event) => { + const key = (e as CustomEvent<{ key: string }>).detail.key + setSelectedPlan(key) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + const el = fourColRef.current + if (!el) return + el.options = [ + { key: 'development', label: 'Development', icon: 'Code' }, + { key: 'staging', label: 'Staging', icon: 'FlaskConical' }, + { key: 'preview', label: 'Preview', icon: 'Eye' }, + { key: 'production', label: 'Production', icon: 'Globe' }, + ] + el.setAttribute('value', 'development') + el.setAttribute('aria-label', 'Select an environment') + el.setAttribute('columns', '4') + const handler = (e: Event) => { + const key = (e as CustomEvent<{ key: string }>).detail.key + setSelectedEnv(key) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +

+ Open the browser console to see tc-change events. +

+
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default CardOptionsDemo diff --git a/examples/src/web-components/CarouselDemo.tsx b/examples/src/web-components/CarouselDemo.tsx new file mode 100644 index 00000000..aa27c221 --- /dev/null +++ b/examples/src/web-components/CarouselDemo.tsx @@ -0,0 +1,260 @@ +import React from 'react' + +const CarouselDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + +
+ Slide 1 +
+
+ Slide 2 +
+
+ Slide 3 +
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
+ Slide 1 +
+
+ Slide 2 +
+
+ Slide 3 +
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
+ Slide 1 +
+
+ Slide 2 +
+
+ Slide 3 +
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
+ Slide 1 (fade) +
+
+ Slide 2 (fade) +
+
+ Slide 3 (fade) +
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
+ Auto 1 +
+
+ Auto 2 +
+
+ Auto 3 +
+ {/* @ts-ignore */} +
+
+
+
+
+
+
+) + +export default CarouselDemo diff --git a/examples/src/web-components/CdnMapDemo.tsx b/examples/src/web-components/CdnMapDemo.tsx new file mode 100644 index 00000000..3df0f8e7 --- /dev/null +++ b/examples/src/web-components/CdnMapDemo.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useRef } from 'react' + +const CdnMapDemo: React.FC = () => { + const mixedRef = useRef(null) + const primaryOnlyRef = useRef(null) + const accentOnlyRef = useRef(null) + const tallRef = useRef(null) + + useEffect(() => { + if (mixedRef.current) { + mixedRef.current.nodes = [ + { top: '20%', left: '15%', variant: 'primary', label: 'NYC' }, + { top: '35%', left: '55%', variant: 'accent', label: 'AMS' }, + { top: '60%', left: '30%', variant: 'primary', label: 'LAX' }, + { top: '25%', left: '78%', variant: 'accent', label: 'SIN' }, + { top: '70%', left: '65%', variant: 'primary', label: 'SYD' }, + ] + } + }, []) + + useEffect(() => { + if (primaryOnlyRef.current) { + primaryOnlyRef.current.nodes = [ + { top: '30%', left: '20%', variant: 'primary', label: 'ORD' }, + { top: '50%', left: '50%', variant: 'primary', label: 'FRA' }, + { top: '70%', left: '75%', variant: 'primary', label: 'NRT' }, + ] + } + }, []) + + useEffect(() => { + if (accentOnlyRef.current) { + accentOnlyRef.current.nodes = [ + { top: '25%', left: '40%', variant: 'accent', label: 'CDG' }, + { top: '65%', left: '60%', variant: 'accent', label: 'GRU' }, + ] + } + }, []) + + useEffect(() => { + if (tallRef.current) { + tallRef.current.nodes = [ + { top: '10%', left: '10%', variant: 'primary', label: 'SEA' }, + { top: '30%', left: '45%', variant: 'accent', label: 'LHR' }, + { top: '55%', left: '25%', variant: 'primary', label: 'MIA' }, + { top: '75%', left: '70%', variant: 'primary', label: 'HKG' }, + { top: '45%', left: '80%', variant: 'accent', label: 'BOM' }, + { top: '85%', left: '50%', variant: 'primary', label: 'JNB' }, + ] + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default CdnMapDemo diff --git a/examples/src/web-components/ChangelogDemo.tsx b/examples/src/web-components/ChangelogDemo.tsx new file mode 100644 index 00000000..4ccbbe19 --- /dev/null +++ b/examples/src/web-components/ChangelogDemo.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useRef } from 'react' + +const ENTRIES_FULL = [ + { + date: '2026-06-01', + title: 'v3.0.0 — Major release', + description: + 'Complete redesign of the component library with new SCSS tokens, improved accessibility, and a framework-free Web Components layer.', + tags: ['breaking', 'feature', 'a11y'], + }, + { + date: '2026-04-15', + title: 'v2.8.0 — Timeline component', + description: + 'Added tc-timeline with alternating left/right layout, icon nodes, status dots, and progress bars.', + tags: ['feature'], + }, + { + date: '2026-03-02', + title: 'v2.7.0 — Form primitives', + description: + 'Shipped tc-input, tc-select, tc-check, tc-radio, tc-switch, tc-range, and tc-floating-label with full keyboard support.', + tags: ['feature'], + }, + { + date: '2026-01-18', + title: 'v2.6.2 — Accessibility fixes', + description: + 'Fixed focus-visible outlines across all interactive elements and added aria-live regions to toast notifications.', + tags: ['fix', 'a11y'], + }, + { + date: '2025-12-10', + title: 'v2.6.0 — Pagination and Navigation', + description: 'Added tc-pagination, tc-breadcrumb, tc-nav, tc-navbar, and tc-scrollspy.', + tags: ['feature'], + }, +] + +const ENTRIES_SHORT = [ + { + date: '2026-06-01', + title: 'v3.0.0 — Major release', + description: 'Complete redesign of the component library.', + tags: ['breaking', 'feature'], + }, + { + date: '2026-04-15', + title: 'v2.8.0 — Timeline component', + description: 'Added tc-timeline with icons and progress bars.', + tags: ['feature'], + }, + { + date: '2026-03-02', + title: 'v2.7.0 — Form primitives', + description: 'Shipped tc-input, tc-select, tc-check, and more.', + tags: ['feature'], + }, +] + +const ChangelogDemo: React.FC = () => { + const fullRef = useRef(null) + const truncatedRef = useRef(null) + + useEffect(() => { + if (fullRef.current) fullRef.current.entries = ENTRIES_FULL + if (truncatedRef.current) truncatedRef.current.entries = ENTRIES_SHORT + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default ChangelogDemo diff --git a/examples/src/web-components/CharacterCreateDemo.tsx b/examples/src/web-components/CharacterCreateDemo.tsx new file mode 100644 index 00000000..e8f0f333 --- /dev/null +++ b/examples/src/web-components/CharacterCreateDemo.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useRef, useState } from 'react' + +const FIELDS = [ + { + id: 'class', + label: 'Class', + type: 'select', + options: [ + { value: 'warrior', label: 'Warrior' }, + { value: 'mage', label: 'Mage' }, + { value: 'ranger', label: 'Ranger' }, + ], + }, + { + id: 'hair', + label: 'Hair colour', + type: 'select', + options: [ + { value: 'black', label: 'Black' }, + { value: 'blonde', label: 'Blonde' }, + { value: 'auburn', label: 'Auburn' }, + ], + }, + { id: 'age', label: 'Age', type: 'number', min: 18, max: 120 }, + { id: 'strength', label: 'Strength', type: 'range', min: 1, max: 20 }, + { id: 'title', label: 'Epithet', type: 'text' }, +] + +const CharacterCreateDemo: React.FC = () => { + const basicRef = useRef(null) + const controlledRef = useRef(null) + + const [summary, setSummary] = useState('(nothing confirmed yet)') + const [liveName, setLiveName] = useState('') + + useEffect(() => { + const el = basicRef.current + if (!el) return + el.fields = FIELDS + + const onName = (e: Event) => setLiveName((e as CustomEvent<{ value: string }>).detail.value) + const onConfirm = (e: Event) => { + const detail = ( + e as CustomEvent<{ name: string; values: Record }> + ).detail + setSummary(JSON.stringify({ name: detail.name, ...detail.values })) + } + el.addEventListener('tc-name', onName) + el.addEventListener('tc-confirm', onConfirm) + return () => { + el.removeEventListener('tc-name', onName) + el.removeEventListener('tc-confirm', onConfirm) + } + }, []) + + useEffect(() => { + const el = controlledRef.current + if (!el) return + el.fields = FIELDS + el.values = { class: 'mage', hair: 'auburn', age: 31, strength: 14, title: 'the Bold' } + el.name = 'Lyra' + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + +
+ Live name: {liveName || '(empty)'} +
+
+ Last confirmed: {summary} +
+
+ + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default CharacterCreateDemo diff --git a/examples/src/web-components/CharacterSelectDemo.tsx b/examples/src/web-components/CharacterSelectDemo.tsx new file mode 100644 index 00000000..92ee604a --- /dev/null +++ b/examples/src/web-components/CharacterSelectDemo.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useRef, useState } from 'react' + +const CHARACTERS = [ + { + id: 'aria', + name: 'Aria Voss', + role: 'Vanguard', + description: + 'Front-line specialist who soaks damage and shields allies behind a kinetic barrier.', + stats: [ + { label: 'Health', value: 1280 }, + { label: 'Armour', value: 'Heavy' }, + { label: 'Speed', value: 6 }, + ], + }, + { + id: 'kade', + name: 'Kade Rin', + role: 'Marksman', + description: 'Long-range damage dealer with a charged rail shot and rapid repositioning.', + stats: [ + { label: 'Health', value: 840 }, + { label: 'Armour', value: 'Light' }, + { label: 'Speed', value: 9 }, + ], + }, + { + id: 'sol', + name: 'Sol', + role: 'Support', + description: 'Keeps the squad alive with area heals and a revive beacon.', + stats: [ + { label: 'Health', value: 960 }, + { label: 'Armour', value: 'Medium' }, + { label: 'Speed', value: 7 }, + ], + }, + { + id: 'nyx', + name: 'Nyx', + role: 'Infiltrator', + locked: true, + description: 'Cloaks and flanks — unlocked at account level 20.', + stats: [ + { label: 'Health', value: 720 }, + { label: 'Armour', value: 'Light' }, + { label: 'Speed', value: 10 }, + ], + }, +] + +const CharacterSelectDemo: React.FC = () => { + const basicRef = useRef(null) + + const [selected, setSelected] = useState('aria') + const [confirmed, setConfirmed] = useState( + '(none — double-click or use Enter then a confirm flow)', + ) + + useEffect(() => { + const el = basicRef.current + if (!el) return + el.characters = CHARACTERS + + const onSelect = (e: Event) => setSelected((e as CustomEvent<{ id: string }>).detail.id) + const onConfirm = (e: Event) => setConfirmed((e as CustomEvent<{ id: string }>).detail.id) + el.addEventListener('tc-select', onSelect) + el.addEventListener('tc-confirm', onConfirm) + return () => { + el.removeEventListener('tc-select', onSelect) + el.removeEventListener('tc-confirm', onConfirm) + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + +
+ Selected: {selected} +
+
+ Last confirmed: {confirmed} +
+
+
+
+
+
+
+ ) +} + +export default CharacterSelectDemo diff --git a/examples/src/web-components/ChartContainerDemo.tsx b/examples/src/web-components/ChartContainerDemo.tsx new file mode 100644 index 00000000..b79cd111 --- /dev/null +++ b/examples/src/web-components/ChartContainerDemo.tsx @@ -0,0 +1,225 @@ +import React, { useEffect, useRef } from 'react' + +const ChartContainerDemo: React.FC = () => { + const withLegendRef = useRef(null) + const withActionsRef = useRef(null) + const fullRef = useRef(null) + const emptyCustomRef = useRef(null) + + useEffect(() => { + // Legend-only example + const legendEl = withLegendRef.current + if (legendEl) { + legendEl.legend = + 'Monthly Revenue' + } + + // Actions-only example + const actionsEl = withActionsRef.current + if (actionsEl) { + actionsEl.actions = + '' + } + + // Full example: legend + actions + const fullEl = fullRef.current + if (fullEl) { + fullEl.legend = + '● Series A○ Series B' + fullEl.actions = + '' + } + + // Empty with custom emptySlot + const emptyCustomEl = emptyCustomRef.current + if (emptyCustomEl) { + emptyCustomEl.emptySlot = + 'No records found for the selected date range.' + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+ Chart body goes here +
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ Chart body goes here +
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ Chart body goes here +
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ Chart body goes here +
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ No header, chart only +
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default ChartContainerDemo diff --git a/examples/src/web-components/ChatWindowDemo.tsx b/examples/src/web-components/ChatWindowDemo.tsx new file mode 100644 index 00000000..40435182 --- /dev/null +++ b/examples/src/web-components/ChatWindowDemo.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useRef, useState } from 'react' + +interface ChatMessage { + id: string + channel?: string + sender: string + body: string + color?: string + system?: boolean +} + +interface ChatChannel { + id: string + label: string + color?: string +} + +const CHANNELS: ChatChannel[] = [ + { id: 'general', label: 'General' }, + { id: 'trade', label: 'Trade', color: 'var(--tc-success)' }, + { id: 'help', label: 'Help', color: 'var(--tc-info)' }, +] + +const INITIAL_MESSAGES: ChatMessage[] = [ + { id: 'm0', sender: 'system', body: 'Connected to general.', system: true }, + { id: 'm1', sender: 'aria', body: 'morning all 👋', color: 'var(--tc-info)' }, + { + id: 'm2', + sender: 'kade', + body: 'anyone running the raid tonight?', + color: 'var(--tc-success)', + }, + { id: 'm3', sender: 'aria', body: 'I am in, give me 10', color: 'var(--tc-info)' }, + { id: 'm4', sender: 'mox', body: 'selling spare keys, DM me', color: 'var(--tc-warning)' }, +] + +let _idCounter = 100 + +const ChatWindowDemo: React.FC = () => { + const basicRef = useRef(null) + const channelsRef = useRef(null) + const eventsRef = useRef(null) + + const [lastSend, setLastSend] = useState(null) + const [activeChannel, setActiveChannel] = useState('general') + + // Basic feed — messages only, no channels. + useEffect(() => { + if (basicRef.current) basicRef.current.messages = [...INITIAL_MESSAGES] + }, []) + + // Channels feed — echoes whatever you send back into the log. + useEffect(() => { + const el = channelsRef.current + if (!el) return + el.channels = [...CHANNELS] + el.messages = [...INITIAL_MESSAGES] + + const onSend = (e: CustomEvent) => { + const { channel, text } = e.detail + el.messages = [ + ...el.messages, + { + id: `you-${++_idCounter}`, + channel, + sender: 'you', + body: text, + color: 'var(--tc-app-accent)', + }, + ] + } + el.addEventListener('tc-send', onSend) + return () => el.removeEventListener('tc-send', onSend) + }, []) + + // Events demo — surfaces tc-send and tc-channel-change detail payloads. + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.channels = [...CHANNELS] + el.messages = [...INITIAL_MESSAGES] + + const onSend = (e: CustomEvent) => { + setLastSend(`channel="${e.detail.channel}" text="${e.detail.text}"`) + } + const onChannel = (e: CustomEvent) => setActiveChannel(e.detail.id) + el.addEventListener('tc-send', onSend) + el.addEventListener('tc-channel-change', onChannel) + return () => { + el.removeEventListener('tc-send', onSend) + el.removeEventListener('tc-channel-change', onChannel) + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
+ Active channel: {activeChannel} +
+ {lastSend && ( +
+ ✓ tc-send → {lastSend} +
+ )} +
+
+
+
+
+
+ ) +} + +export default ChatWindowDemo diff --git a/examples/src/web-components/CheckDemo.tsx b/examples/src/web-components/CheckDemo.tsx new file mode 100644 index 00000000..58f688f9 --- /dev/null +++ b/examples/src/web-components/CheckDemo.tsx @@ -0,0 +1,78 @@ +import React from 'react' + +const CheckDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+) + +export default CheckDemo diff --git a/examples/src/web-components/CheckboxGroupDemo.tsx b/examples/src/web-components/CheckboxGroupDemo.tsx new file mode 100644 index 00000000..e473d2fa --- /dev/null +++ b/examples/src/web-components/CheckboxGroupDemo.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useRef, useState } from 'react' + +const OPTIONS = [ + { value: 'js', label: 'JavaScript' }, + { value: 'ts', label: 'TypeScript' }, + { value: 'py', label: 'Python' }, + { value: 'go', label: 'Go', disabled: true }, +] + +const CheckboxGroupDemo: React.FC = () => { + const basicRef = useRef(null) + const inlineRef = useRef(null) + const controlledRef = useRef(null) + const requiredRef = useRef(null) + + const [selection, setSelection] = useState([]) + const [controlledValue, setControlledValue] = useState(['js', 'ts']) + + useEffect(() => { + const els = [basicRef, inlineRef, requiredRef].map((r) => r.current) + els.forEach((el) => { + if (el) el.options = OPTIONS + }) + }, []) + + useEffect(() => { + const el = basicRef.current + if (!el) return + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ value: string[] }>).detail + setSelection(detail.value) + console.log('tc-change:', detail.value) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + const el = controlledRef.current + if (!el) return + el.options = OPTIONS + el.value = controlledValue + + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ value: string[] }>).detail + setControlledValue(detail.value) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + const el = controlledRef.current + if (el) el.value = controlledValue + }, [controlledValue]) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + {selection.length > 0 && ( +
+ Selected: {selection.join(', ')} +
+ )} +
+ + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
+ Controlled value:{' '} + {controlledValue.length ? controlledValue.join(', ') : '(none)'} +
+
+ + +
+
+ + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default CheckboxGroupDemo diff --git a/examples/src/web-components/ChipDemo.tsx b/examples/src/web-components/ChipDemo.tsx new file mode 100644 index 00000000..19bc0e7c --- /dev/null +++ b/examples/src/web-components/ChipDemo.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useRef, useState } from 'react' + +type ChipVariant = 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'danger' + +const ALL_VARIANTS: ChipVariant[] = ['primary', 'secondary', 'info', 'success', 'warning', 'danger'] + +const TAGS = ['React', 'TypeScript', 'Node.js', 'GraphQL', 'Rust'] + +function SelectableChips() { + const [selected, setSelected] = useState(null) + const containerRef = useRef(null) + + useEffect(() => { + const el = containerRef.current + if (!el) return + const handler = (e: Event) => { + const chip = (e.target as HTMLElement).closest('tc-chip') + if (!chip) return + const key = chip.getAttribute('data-key') + if (!key) return + setSelected((prev) => (prev === key ? null : key)) + } + el.addEventListener('tc-click', handler) + return () => el.removeEventListener('tc-click', handler) + }, []) + + useEffect(() => { + const el = containerRef.current + if (!el) return + el.querySelectorAll('tc-chip').forEach((chip) => { + const key = chip.getAttribute('data-key') + if (key === selected) chip.setAttribute('selected', '') + else chip.removeAttribute('selected') + }) + }, [selected]) + + return ( +
+ {TAGS.map((tag) => ( + + {/* @ts-ignore */} + {tag} + + ))} +
+ ) +} + +function RemovableChipExample() { + const [visible, setVisible] = useState(true) + const chipRef = useRef(null) + + useEffect(() => { + const el = chipRef.current + if (!el) return + const handler = () => setVisible(false) + el.addEventListener('tc-remove', handler) + return () => el.removeEventListener('tc-remove', handler) + }, []) + + if (!visible) { + return ( + + ) + } + + return ( + <> + {/* @ts-ignore */} + + Removable chip + + + ) +} + +const ChipDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {ALL_VARIANTS.map((v) => ( + + {/* @ts-ignore */} + {v} + + ))} +
+
+ + + + + + +
+ {/* @ts-ignore */} + Tag + {/* @ts-ignore */} + + Star + + {/* @ts-ignore */} + + Verified + + {/* @ts-ignore */} + + Alert + + {/* @ts-ignore */} + + Info + +
+
+ + +
+ {/* @ts-ignore */} + Messages + {/* @ts-ignore */} + + Errors + + {/* @ts-ignore */} + + Notifications + +
+
+ + + + + + +
+ {/* @ts-ignore */} + Disabled + {/* @ts-ignore */} + + Disabled Primary + + {/* @ts-ignore */} + + Disabled Removable + + {/* @ts-ignore */} + + Disabled Selected + +
+
+
+
+
+
+
+) + +export default ChipDemo diff --git a/examples/src/web-components/ChipGroupDemo.tsx b/examples/src/web-components/ChipGroupDemo.tsx new file mode 100644 index 00000000..252e516c --- /dev/null +++ b/examples/src/web-components/ChipGroupDemo.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useRef } from 'react' + +const ChipGroupDemo: React.FC = () => { + const basicRef = useRef(null) + const borderedRef = useRef(null) + const subtitleRef = useRef(null) + const toggleLogRef = useRef(null) + + useEffect(() => { + const el = basicRef.current + if (!el) return + el.items = [ + { id: 'react', label: 'React', selected: true }, + { id: 'typescript', label: 'TypeScript', selected: true }, + { id: 'nodejs', label: 'Node.js' }, + { id: 'rust', label: 'Rust', icon: 'Zap' }, + { id: 'graphql', label: 'GraphQL', count: 12 }, + { id: 'legacy', label: 'Legacy', disabled: true }, + ] + const handler = (e: CustomEvent) => { + console.log('[tc-toggle] id:', e.detail.id) + } + el.addEventListener('tc-toggle', handler) + return () => el.removeEventListener('tc-toggle', handler) + }, []) + + useEffect(() => { + const el = borderedRef.current + if (!el) return + el.items = [ + { id: 'frontend', label: 'Frontend', selected: true, variant: 'primary' }, + { id: 'backend', label: 'Backend', variant: 'info' }, + { id: 'devops', label: 'DevOps', variant: 'success' }, + { id: 'design', label: 'Design', count: 3 }, + ] + }, []) + + useEffect(() => { + const el = subtitleRef.current + if (!el) return + el.items = [ + { id: 'bug', label: 'Bug', icon: 'Bug', variant: 'danger' }, + { id: 'feature', label: 'Feature', icon: 'Star', variant: 'success' }, + { id: 'docs', label: 'Docs', icon: 'FileText' }, + { id: 'chore', label: 'Chore', icon: 'Settings' }, + ] + }, []) + + useEffect(() => { + const el = toggleLogRef.current + if (!el) return + el.items = [ + { id: 'a', label: 'Alpha' }, + { id: 'b', label: 'Beta', selected: true }, + { id: 'c', label: 'Gamma' }, + ] + el.onToggle = (id: string) => { + console.log('[onToggle callback] id:', id) + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default ChipGroupDemo diff --git a/examples/src/web-components/CircularProgressDemo.tsx b/examples/src/web-components/CircularProgressDemo.tsx new file mode 100644 index 00000000..5db52bf7 --- /dev/null +++ b/examples/src/web-components/CircularProgressDemo.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useRef, useState } from 'react' + +const CircularProgressDemo: React.FC = () => { + // Live-updating value, set via the JS `value` property on a ref. + const liveRef = useRef(null) + const [value, setValue] = useState(25) + + useEffect(() => { + const id = setInterval(() => { + setValue((v) => (v >= 100 ? 0 : v + 5)) + }, 600) + return () => clearInterval(id) + }, []) + + useEffect(() => { + if (liveRef.current) liveRef.current.value = value + }, [value]) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default CircularProgressDemo diff --git a/examples/src/web-components/CloseButtonDemo.tsx b/examples/src/web-components/CloseButtonDemo.tsx new file mode 100644 index 00000000..aa356c8e --- /dev/null +++ b/examples/src/web-components/CloseButtonDemo.tsx @@ -0,0 +1,45 @@ +import React from 'react' + +const CloseButtonDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+) + +export default CloseButtonDemo diff --git a/examples/src/web-components/CodeLabelCellDemo.tsx b/examples/src/web-components/CodeLabelCellDemo.tsx new file mode 100644 index 00000000..fe3018d2 --- /dev/null +++ b/examples/src/web-components/CodeLabelCellDemo.tsx @@ -0,0 +1,108 @@ +import React from 'react' + +const CodeLabelCellDemo: React.FC = () => { + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierStatusAmount
+ {/* @ts-ignore */} + + Paid$49.00
+ {/* @ts-ignore */} + + Pending$199.00
+ {/* @ts-ignore */} + + Overdue$9.00
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default CodeLabelCellDemo diff --git a/examples/src/web-components/CodeSnippetDemo.tsx b/examples/src/web-components/CodeSnippetDemo.tsx new file mode 100644 index 00000000..ada13abd --- /dev/null +++ b/examples/src/web-components/CodeSnippetDemo.tsx @@ -0,0 +1,133 @@ +import React, { useRef, useEffect, useState } from 'react' + +const JS_CODE = [ + 'async function fetchUser(id) {', + ' const res = await fetch(`/api/users/${id}`)', + ' if (!res.ok) throw new Error(`HTTP ${res.status}`)', + ' return res.json()', + '}', + '', + 'fetchUser(42).then(u => console.log(u.name))', +].join('\n') + +const TS_CODE = [ + 'interface Config {', + ' host: string', + ' port: number', + ' debug?: boolean', + '}', + '', + 'function createServer(config: Config): void {', + ' const { host, port, debug = false } = config', + ' if (debug) console.log(`Starting on ${host}:${port}`)', + '}', +].join('\n') + +const BASH_CODE = [ + '#!/usr/bin/env bash', + '# Deploy script', + 'set -euo pipefail', + '', + 'APP_NAME="my-app"', + 'VERSION=${1:-latest}', + '', + 'echo "Deploying $APP_NAME @ $VERSION"', + 'git pull origin main', + 'npm install --production', + 'npm run build', +].join('\n') + +const SLOT_CODE = ['const greet = (name: string) =>', ' `Hello, ${name}!`'].join('\n') + +const CodeSnippetDemo: React.FC = () => { + const tsCopyRef = useRef(null) + const [lastCopied, setLastCopied] = useState(null) + + useEffect(() => { + const tsEl = tsCopyRef.current + if (!tsEl) return + + const handler = (e: CustomEvent<{ code: string }>) => { + setLastCopied(e.detail.code.slice(0, 40) + '…') + } + + tsEl.onCopy = (code: string) => { + // JS property callback also fires + console.log('[CodeSnippet] onCopy callback, length:', code.length) + } + tsEl.addEventListener('tc-copy', handler) + return () => tsEl.removeEventListener('tc-copy', handler) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {lastCopied && ( +

+ tc-copy fired. First 40 chars:{' '} + {lastCopied} +

+ )} +
+ + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + {SLOT_CODE} + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default CodeSnippetDemo diff --git a/examples/src/web-components/CodeWithOutputDemo.tsx b/examples/src/web-components/CodeWithOutputDemo.tsx new file mode 100644 index 00000000..0b871559 --- /dev/null +++ b/examples/src/web-components/CodeWithOutputDemo.tsx @@ -0,0 +1,110 @@ +import React, { useRef, useEffect } from 'react' + +const JS_CODE = + "function greet(name) {\n return `Hello, ${name}!`;\n}\n\nconsole.log(greet('World'));" +const TS_CODE = + 'function processName(name: string): string {\n return name.toUpperCase();\n}\n\nprocessName(undefined as any);' +const BASH_CODE = 'git clone https://github.com/example/my-app.git\ncd my-app && npm install' +const STACKED_CODE = 'const a = 1;\nconst b = 2;\nconsole.log(a + b);' + +const CodeWithOutputDemo: React.FC = () => { + const splitRef = useRef(null) + const stackedRef = useRef(null) + const errorRef = useRef(null) + const bashRef = useRef(null) + + useEffect(() => { + if (splitRef.current) { + splitRef.current.output = 'Hello, World!' + } + if (stackedRef.current) { + stackedRef.current.output = '3' + } + if (errorRef.current) { + errorRef.current.error = + "TypeError: Cannot read properties of undefined (reading 'toUpperCase')\n" + + ' at processName (index.ts:2:16)\n' + + ' at :5:1' + } + if (bashRef.current) { + bashRef.current.output = + "Cloning into 'my-app'...\nremote: Enumerating objects: 42, done.\nadded 312 packages in 8s" + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + Slotted rich title + 3.141592653589793 + + +
+
+
+
+
+ ) +} + +export default CodeWithOutputDemo diff --git a/examples/src/web-components/CodexDemo.tsx b/examples/src/web-components/CodexDemo.tsx new file mode 100644 index 00000000..ba60a85b --- /dev/null +++ b/examples/src/web-components/CodexDemo.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef, useState } from 'react' + +const ENTRIES = [ + { + id: 'gloomfang', + name: 'Gloomfang', + icon: 'skull', + discovered: true, + description: + 'A pack hunter that stalks the lower caverns, striking from darkness before retreating.', + stats: [ + { label: 'Threat', value: 'High' }, + { label: 'Health', value: 2400 }, + { label: 'Encounters', value: 17 }, + ], + }, + { + id: 'emberwisp', + name: 'Emberwisp', + icon: 'flame', + discovered: true, + description: 'A drifting mote of living fire. Harmless alone, deadly in swarms.', + stats: [ + { label: 'Threat', value: 'Low' }, + { label: 'Health', value: 90 }, + { label: 'Encounters', value: 132 }, + ], + }, + { + id: 'tidecaller', + name: 'Tidecaller', + icon: 'waves', + discovered: true, + description: 'Ancient leviathan that commands the coastal storms.', + stats: [ + { label: 'Threat', value: 'Boss' }, + { label: 'Health', value: 18000 }, + { label: 'Encounters', value: 2 }, + ], + }, + { id: 'unknown-1', name: 'Unknown', discovered: false }, + { id: 'unknown-2', name: 'Unknown', discovered: false }, +] + +const CodexDemo: React.FC = () => { + const ref = useRef(null) + const [selected, setSelected] = useState('gloomfang') + + useEffect(() => { + const el = ref.current + if (!el) return + el.entries = ENTRIES + + const onSelect = (e: Event) => setSelected((e as CustomEvent<{ id: string }>).detail.id) + el.addEventListener('tc-select', onSelect) + return () => el.removeEventListener('tc-select', onSelect) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + +
+ Selected: {selected} +
+
+
+
+
+
+
+ ) +} + +export default CodexDemo diff --git a/examples/src/web-components/ColDemo.tsx b/examples/src/web-components/ColDemo.tsx new file mode 100644 index 00000000..298c3b2f --- /dev/null +++ b/examples/src/web-components/ColDemo.tsx @@ -0,0 +1,146 @@ +import React from 'react' + +const Box = ({ label }: { label: string }) => ( +
+ {label} +
+) + +const ColDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + +
+
+
+
+
+) + +export default ColDemo diff --git a/examples/src/web-components/CollapseDemo.tsx b/examples/src/web-components/CollapseDemo.tsx new file mode 100644 index 00000000..8c4f8821 --- /dev/null +++ b/examples/src/web-components/CollapseDemo.tsx @@ -0,0 +1,90 @@ +import React, { useRef } from 'react' + +const CollapseDemo: React.FC = () => { + const collapseRef = useRef(null) + const hCollapseRef = useRef(null) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ + + +
+ {/* @ts-ignore */} + +
+ This content is collapsible. Click Toggle{' '} + to show or hide it using Bootstrap's Collapse plugin. +
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
+ This panel starts expanded because the open{' '} + attribute is present. +
+ {/* @ts-ignore */} +
+
+ + +
+ +
+ {/* @ts-ignore */} + +
+ This collapses horizontally (width transition) instead of + vertically. +
+ {/* @ts-ignore */} +
+
+
+
+
+
+
+ ) +} + +export default CollapseDemo diff --git a/examples/src/web-components/ColorPickerDemo.tsx b/examples/src/web-components/ColorPickerDemo.tsx new file mode 100644 index 00000000..fe1a9686 --- /dev/null +++ b/examples/src/web-components/ColorPickerDemo.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useRef, useState } from 'react' + +const PRESET_HEX = [ + '#0f172a', + '#1e293b', + '#334155', + '#475569', + '#64748b', + '#94a3b8', + '#cbd5e1', + '#f1f5f9', + '#dc2626', + '#ea580c', + '#d97706', + '#ca8a04', + '#16a34a', + '#0284c7', + '#7c3aed', + '#db2777', +] + +const PRESET_OPTIONS = [ + { value: '#0f172a', label: 'Slate 900' }, + { value: '#1e293b', label: 'Slate 800' }, + { value: '#334155', label: 'Slate 700' }, + { value: '#475569', label: 'Slate 600' }, + { value: '#64748b', label: 'Slate 500' }, + { value: '#94a3b8', label: 'Slate 400' }, + { value: '#dc2626', label: 'Red' }, + { value: '#16a34a', label: 'Green' }, + { value: '#0284c7', label: 'Blue' }, + { value: '#7c3aed', label: 'Purple' }, + { value: '#db2777', label: 'Pink' }, + { value: '#d97706', label: 'Amber' }, +] + +const ColorPickerDemo: React.FC = () => { + const hexRef = useRef(null) + const optionsRef = useRef(null) + const columnsRef = useRef(null) + const loadingRef = useRef(null) + + const [hexValue, setHexValue] = useState('#334155') + const [optionsValue, setOptionsValue] = useState('#dc2626') + + useEffect(() => { + const el = hexRef.current + if (!el) return + el.colors = PRESET_HEX + el.value = hexValue + const handler = (e: Event) => setHexValue((e as CustomEvent).detail.value) + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + const el = optionsRef.current + if (!el) return + el.colors = PRESET_OPTIONS + el.value = optionsValue + const handler = (e: Event) => setOptionsValue((e as CustomEvent).detail.value) + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + const el = columnsRef.current + if (!el) return + el.colors = PRESET_HEX + }, []) + + useEffect(() => { + const el = loadingRef.current + if (!el) return + el.colors = [] + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
Selected: {hexValue}
+
+
+ + +
+ {/* @ts-ignore */} + +
Selected: {optionsValue}
+
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default ColorPickerDemo diff --git a/examples/src/web-components/ColoredCardDemo.tsx b/examples/src/web-components/ColoredCardDemo.tsx new file mode 100644 index 00000000..a1d1911f --- /dev/null +++ b/examples/src/web-components/ColoredCardDemo.tsx @@ -0,0 +1,75 @@ +import React from 'react' + +const ColoredCardDemo: React.FC = () => { + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default ColoredCardDemo diff --git a/examples/src/web-components/ComboBoxDemo.tsx b/examples/src/web-components/ComboBoxDemo.tsx new file mode 100644 index 00000000..cf611a8e --- /dev/null +++ b/examples/src/web-components/ComboBoxDemo.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useRef, useState } from 'react' + +const FRAMEWORK_OPTIONS = [ + { value: 'react', label: 'React', keywords: ['meta', 'jsx', 'hooks'] }, + { value: 'vue', label: 'Vue', keywords: ['progressive', 'sfc'] }, + { value: 'svelte', label: 'Svelte', keywords: ['compiler'] }, + { value: 'angular', label: 'Angular', keywords: ['google', 'rxjs'] }, + { value: 'solid', label: 'SolidJS', keywords: ['reactivity', 'signals'] }, + { value: 'qwik', label: 'Qwik', keywords: ['resumable'] }, + { value: 'preact', label: 'Preact', keywords: ['lightweight'] }, +] + +const TIMEZONE_OPTIONS = [ + { value: 'utc', label: 'UTC' }, + { value: 'cet', label: 'Central European Time' }, + { value: 'est', label: 'Eastern Standard Time' }, + { value: 'pst', label: 'Pacific Standard Time' }, + { value: 'jst', label: 'Japan Standard Time' }, +] + +const ComboBoxDemo: React.FC = () => { + const basicRef = useRef(null) + const preselectedRef = useRef(null) + const disabledRef = useRef(null) + const [selected, setSelected] = useState(null) + const [tz, setTz] = useState('cet') + + useEffect(() => { + if (!basicRef.current) return + basicRef.current.options = FRAMEWORK_OPTIONS + const handler = (e: Event) => { + setSelected((e as CustomEvent<{ value: string }>).detail.value) + } + basicRef.current.addEventListener('tc-change', handler) + return () => basicRef.current?.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + if (!preselectedRef.current) return + preselectedRef.current.options = TIMEZONE_OPTIONS + const handler = (e: Event) => { + setTz((e as CustomEvent<{ value: string }>).detail.value) + } + preselectedRef.current.addEventListener('tc-change', handler) + return () => preselectedRef.current?.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + if (!disabledRef.current) return + disabledRef.current.options = FRAMEWORK_OPTIONS + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +

+ Options are set via the options JS property. Click + the trigger to open; type to filter (matches label,{' '} + value, and keywords); press Enter to + pick the first match; Escape closes. +

+
+ {/* @ts-ignore */} + +
+ {selected && ( +

+ Selected: {selected} +

+ )} +
+ + +

+ Pass a value attribute to set an initial selection; + the trigger shows that option's label and the matching row is + highlighted in the list. +

+
+ {/* @ts-ignore */} + +
+

+ Current value: {tz} +

+
+ + +

+ The boolean disabled attribute dims the trigger and + prevents the dropdown from opening. +

+
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default ComboBoxDemo diff --git a/examples/src/web-components/ComboCounterDemo.tsx b/examples/src/web-components/ComboCounterDemo.tsx new file mode 100644 index 00000000..4ee1632e --- /dev/null +++ b/examples/src/web-components/ComboCounterDemo.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useRef, useState } from 'react' + +const ComboCounterDemo: React.FC = () => { + const liveRef = useRef(null) + const [combo, setCombo] = useState(3) + const [timer, setTimer] = useState(1) + + // Drive the live readout's combo + draining timer through JS properties. + useEffect(() => { + const el = liveRef.current + if (!el) return + el.combo = combo + el.timer = timer + }, [combo, timer]) + + // Drain the timer bar; reset the combo when it empties. + useEffect(() => { + const id = window.setInterval(() => { + setTimer((prev) => { + const next = prev - 0.05 + if (next <= 0) { + setCombo(0) + return 1 + } + return next + }) + }, 150) + return () => window.clearInterval(id) + }, []) + + const hit = () => { + setCombo((prev) => prev + 1) + setTimer(1) + } + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + +

+ A combo of 1 renders nothing — there is no card + below. +

+ {/* @ts-ignore */} + +
+ + +
+ {/* @ts-ignore */} + + Hit +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default ComboCounterDemo diff --git a/examples/src/web-components/CommandPaletteDemo.tsx b/examples/src/web-components/CommandPaletteDemo.tsx new file mode 100644 index 00000000..ff3734d0 --- /dev/null +++ b/examples/src/web-components/CommandPaletteDemo.tsx @@ -0,0 +1,148 @@ +import React, { useRef, useState, useEffect } from 'react' + +interface CommandPaletteItem { + id: string + label: string + group?: string + icon?: string + shortcut?: string + keywords?: string[] +} + +const ITEMS: CommandPaletteItem[] = [ + { + id: 'new-file', + label: 'New File', + group: 'File', + icon: 'file-plus', + shortcut: '⌘ N', + keywords: ['create', 'document'], + }, + { + id: 'open-file', + label: 'Open File…', + group: 'File', + icon: 'folder-open', + shortcut: '⌘ O', + keywords: ['browse'], + }, + { id: 'save', label: 'Save', group: 'File', icon: 'save', shortcut: '⌘ S' }, + { id: 'cut', label: 'Cut', group: 'Edit', icon: 'scissors', shortcut: '⌘ X' }, + { id: 'copy', label: 'Copy', group: 'Edit', icon: 'copy', shortcut: '⌘ C' }, + { id: 'paste', label: 'Paste', group: 'Edit', icon: 'clipboard', shortcut: '⌘ V' }, + { + id: 'find', + label: 'Find in File', + group: 'Edit', + icon: 'search', + shortcut: '⌘ F', + keywords: ['search'], + }, + { + id: 'toggle-theme', + label: 'Toggle Dark Theme', + group: 'View', + icon: 'moon', + keywords: ['dark', 'light', 'appearance'], + }, + { + id: 'zen', + label: 'Enter Zen Mode', + group: 'View', + icon: 'maximize', + keywords: ['fullscreen', 'focus'], + }, + { id: 'settings', label: 'Open Settings', group: 'View', icon: 'settings', shortcut: '⌘ ,' }, +] + +const CommandPaletteDemo: React.FC = () => { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [lastSelected, setLastSelected] = useState(null) + const paletteRef = useRef(null) + + // JS-property + events must be wired via ref (React can't set object props + // as attributes, and listens for the tc-* CustomEvents). + useEffect(() => { + const el = paletteRef.current + if (!el) return + el.items = ITEMS + + const onSelect = (e: Event) => { + const item = (e as CustomEvent).detail.item as CommandPaletteItem + setLastSelected(item.label) + setOpen(false) + } + const onClose = () => setOpen(false) + + el.addEventListener('tc-select', onSelect) + el.addEventListener('tc-close', onClose) + return () => { + el.removeEventListener('tc-select', onSelect) + el.removeEventListener('tc-close', onClose) + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ + + {lastSelected && ( + + Last selected: {lastSelected} + + )} +
+

+ Type to filter, ↑/↓ to navigate, ↵ to select, Esc or backdrop + click to close. +

+ + {/* @ts-ignore */} + +
+
+
+
+
+
+ ) +} + +export default CommandPaletteDemo diff --git a/examples/src/web-components/CommandReferenceDemo.tsx b/examples/src/web-components/CommandReferenceDemo.tsx new file mode 100644 index 00000000..fe28b83d --- /dev/null +++ b/examples/src/web-components/CommandReferenceDemo.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useRef } from 'react' + +const sampleCommands = [ + { + name: 'init', + usage: 'init [directory]', + description: + 'Initialise a new project in the given directory, or the current directory if omitted.', + aliases: ['i', 'create'], + flags: [ + { + flag: '--template ', + description: 'Starter template to use (default: "blank").', + }, + { flag: '--yes', description: 'Skip prompts and accept all defaults.' }, + { flag: '--typescript', description: 'Generate TypeScript configuration files.' }, + ], + }, + { + name: 'build', + usage: 'build [options]', + description: 'Compile and bundle the project for production.', + aliases: ['b'], + flags: [ + { flag: '--watch', description: 'Rebuild on file changes.' }, + { + flag: '--minify', + description: 'Enable output minification (default: true in production).', + }, + { flag: '--out-dir ', description: 'Override the output directory.' }, + { flag: '--sourcemap', description: 'Emit source maps alongside the bundle.' }, + ], + }, + { + name: 'dev', + usage: 'dev [port]', + description: 'Start the development server with HMR.', + flags: [ + { flag: '--port ', description: 'Port to listen on (default: 5173).' }, + { flag: '--host', description: 'Expose the server on the local network.' }, + { flag: '--open', description: 'Open the browser automatically on start.' }, + ], + }, + { + name: 'lint', + usage: 'lint [files...]', + description: + 'Run the linter on source files. Exits with a non-zero code if issues are found.', + aliases: ['check'], + flags: [ + { flag: '--fix', description: 'Auto-fix fixable issues.' }, + { flag: '--quiet', description: 'Report errors only, suppress warnings.' }, + ], + }, + { + name: 'test', + usage: 'test [pattern]', + description: + 'Execute the test suite. An optional glob pattern narrows which files are run.', + aliases: ['t', 'vitest'], + flags: [ + { flag: '--watch', description: 'Re-run tests on file changes.' }, + { flag: '--coverage', description: 'Collect and report code coverage.' }, + { flag: '--reporter ', description: 'Output reporter (dot, verbose, json).' }, + ], + }, + { + name: 'publish', + usage: 'publish [version]', + description: 'Bump the version, build, and publish to the registry.', + flags: [ + { flag: '--tag ', description: 'NPM dist-tag (default: "latest").' }, + { flag: '--dry-run', description: 'Run all steps without actually publishing.' }, + { flag: '--access ', description: 'Package access level.' }, + ], + }, +] + +const minimalCommands = [ + { + name: 'help', + description: 'Display usage information for a command.', + }, + { + name: 'version', + description: 'Print the current CLI version.', + aliases: ['--version', '-v'], + }, +] + +const CommandReferenceDemo: React.FC = () => { + const fullRef = useRef(null) + const noSearchRef = useRef(null) + const titledRef = useRef(null) + const slottedRef = useRef(null) + const minimalRef = useRef(null) + + useEffect(() => { + if (fullRef.current) fullRef.current.commands = sampleCommands + if (noSearchRef.current) noSearchRef.current.commands = sampleCommands + if (titledRef.current) titledRef.current.commands = sampleCommands + if (slottedRef.current) slottedRef.current.commands = minimalCommands + if (minimalRef.current) minimalRef.current.commands = minimalCommands + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + my-cli{' '} + + — command reference + + + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default CommandReferenceDemo diff --git a/examples/src/web-components/CommunityLinksDemo.tsx b/examples/src/web-components/CommunityLinksDemo.tsx new file mode 100644 index 00000000..4caf37a1 --- /dev/null +++ b/examples/src/web-components/CommunityLinksDemo.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useRef } from 'react' + +const LINKS_BASIC = [ + { label: 'GitHub', href: '#', icon: 'github', count: '12.4k' }, + { label: 'Discord', href: '#', icon: 'discord', count: '8.2k' }, + { label: 'Twitter / X', href: '#', icon: 'x', count: '5.1k' }, +] + +const LINKS_FULL = [ + { + label: 'GitHub', + href: '#', + icon: 'github', + count: '12.4k', + description: 'Source code & issues', + }, + { + label: 'Discord', + href: '#', + icon: 'discord', + count: '8.2k', + description: 'Live chat & support', + }, + { label: 'Twitter / X', href: '#', icon: 'x', count: '5.1k', description: 'Announcements' }, + { + label: 'YouTube', + href: '#', + icon: 'youtube', + count: '2.3k', + description: 'Tutorials & demos', + }, + { + label: 'Reddit', + href: '#', + icon: 'reddit', + count: '1.8k', + description: 'Community discussions', + }, + { label: 'npm', href: '#', icon: 'npm', count: '450k', description: 'Package registry' }, +] + +const LINKS_NO_COUNT = [ + { label: 'GitHub', href: '#', icon: 'github' }, + { label: 'Discord', href: '#', icon: 'discord' }, + { label: 'Twitter / X', href: '#', icon: 'x' }, + { label: 'Forum', href: '#', icon: 'forum' }, +] + +const CommunityLinksDemo: React.FC = () => { + const basicRef = useRef(null) + const fullRef = useRef(null) + const noCountRef = useRef(null) + const slotTitleRef = useRef(null) + + useEffect(() => { + if (basicRef.current) basicRef.current.links = LINKS_BASIC + }, []) + + useEffect(() => { + if (fullRef.current) fullRef.current.links = LINKS_FULL + }, []) + + useEffect(() => { + if (noCountRef.current) noCountRef.current.links = LINKS_NO_COUNT + }, []) + + useEffect(() => { + if (slotTitleRef.current) slotTitleRef.current.links = LINKS_BASIC + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + Slotted heading + + + +
+
+
+
+
+ ) +} + +export default CommunityLinksDemo diff --git a/examples/src/web-components/ComparatorDemo.tsx b/examples/src/web-components/ComparatorDemo.tsx new file mode 100644 index 00000000..d072e897 --- /dev/null +++ b/examples/src/web-components/ComparatorDemo.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useRef } from 'react' + +const ComparatorDemo: React.FC = () => { + const fullRef = useRef(null) + const noSummaryRef = useRef(null) + const valueRef = useRef(null) + const minimalRef = useRef(null) + + useEffect(() => { + if (!fullRef.current) return + fullRef.current.left = { name: 'React', icon: 'layers', label: 'Meta' } + fullRef.current.right = { name: 'Vue', icon: 'triangle', label: 'Evan You' } + fullRef.current.features = [ + { label: 'TypeScript support', left: true, right: true }, + { label: 'SSR support', left: true, right: true }, + { label: 'Server components', left: true, right: false }, + { + label: 'Built-in state management', + left: false, + right: true, + description: 'Vue ships Pinia by default', + }, + { label: 'Two-way data binding', left: false, right: true }, + { label: 'Virtual DOM', left: true, right: true }, + { label: 'Mobile framework', left: false, right: false }, + ] + }, []) + + useEffect(() => { + if (!noSummaryRef.current) return + noSummaryRef.current.left = { name: 'PostgreSQL', icon: 'database' } + noSummaryRef.current.right = { name: 'SQLite', icon: 'hard-drive' } + noSummaryRef.current.features = [ + { label: 'Full ACID transactions', left: true, right: true }, + { label: 'Multi-user support', left: true, right: false }, + { label: 'Embedded mode', left: false, right: true }, + { label: 'JSON support', left: true, right: true }, + ] + }, []) + + useEffect(() => { + if (!valueRef.current) return + valueRef.current.left = { name: 'Node.js', icon: 'server' } + valueRef.current.right = { name: 'Deno', icon: 'shield' } + valueRef.current.features = [ + { label: 'GitHub stars', left: 105000, right: 93000, description: 'Approximate count' }, + { label: 'npm packages available', left: 2100000, right: 5000 }, + { label: 'Cold start time (ms)', left: 50, right: 30 }, + { label: 'TypeScript native', left: false, right: true }, + { label: 'Built-in permission model', left: false, right: true }, + ] + }, []) + + useEffect(() => { + if (!minimalRef.current) return + minimalRef.current.left = { name: 'Alpha' } + minimalRef.current.right = { name: 'Beta' } + minimalRef.current.features = [ + { label: 'Speed', left: true, right: false }, + { label: 'Cost', left: false, right: true }, + ] + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default ComparatorDemo diff --git a/examples/src/web-components/CompassBarDemo.tsx b/examples/src/web-components/CompassBarDemo.tsx new file mode 100644 index 00000000..020c61ba --- /dev/null +++ b/examples/src/web-components/CompassBarDemo.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useRef, useState } from 'react' + +const CompassBarDemo: React.FC = () => { + const cardinalsRef = useRef(null) + const markersRef = useRef(null) + const narrowRef = useRef(null) + const liveRef = useRef(null) + + const [heading, setHeading] = useState(0) + + // Markers shared across the cardinal + marker demos. Most use the default ink + // accent; one spends the rare cyan highlight for an objective. + useEffect(() => { + const markers = [ + { id: 'obj', heading: 30, label: 'Objective', color: 'var(--tc-accent)' }, + { id: 'ally', heading: 75, label: 'Ally' }, + { id: 'home', heading: 350, label: 'Base', icon: '⌂' }, + ] + if (markersRef.current) markersRef.current.markers = markers + if (narrowRef.current) narrowRef.current.markers = markers + }, []) + + useEffect(() => { + if (cardinalsRef.current) { + cardinalsRef.current.markers = [{ id: 'waypoint', heading: 120, label: 'Waypoint' }] + } + }, []) + + // A live-tracking strip: slowly sweep the heading so cardinals and markers + // slide across the field of view. + useEffect(() => { + const el = liveRef.current + if (!el) return + el.markers = [ + { id: 'n-marker', heading: 0, label: 'North', color: 'var(--tc-accent)' }, + { id: 'e-marker', heading: 90, label: 'East' }, + { id: 's-marker', heading: 180, label: 'South' }, + { id: 'w-marker', heading: 270, label: 'West' }, + ] + const id = window.setInterval(() => { + setHeading((h) => (h + 2) % 360) + }, 80) + return () => window.clearInterval(id) + }, []) + + useEffect(() => { + if (liveRef.current) liveRef.current.setAttribute('heading', String(heading)) + }, [heading]) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default CompassBarDemo diff --git a/examples/src/web-components/CompassRoseDemo.tsx b/examples/src/web-components/CompassRoseDemo.tsx new file mode 100644 index 00000000..9ac2e907 --- /dev/null +++ b/examples/src/web-components/CompassRoseDemo.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useRef, useState } from 'react' + +const CompassRoseDemo: React.FC = () => { + const liveRef = useRef(null) + const [heading, setHeading] = useState(0) + + // Slowly sweep the heading so the needle rotates continuously, exercising + // the rotation transition. + useEffect(() => { + const id = window.setInterval(() => { + setHeading((h) => (h + 5) % 360) + }, 120) + return () => window.clearInterval(id) + }, []) + + useEffect(() => { + if (liveRef.current) liveRef.current.setAttribute('heading', String(heading)) + }, [heading]) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default CompassRoseDemo diff --git a/examples/src/web-components/CompatibilityMatrixDemo.tsx b/examples/src/web-components/CompatibilityMatrixDemo.tsx new file mode 100644 index 00000000..5d80adc5 --- /dev/null +++ b/examples/src/web-components/CompatibilityMatrixDemo.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useRef } from 'react' + +const CompatibilityMatrixDemo: React.FC = () => { + const fullRef = useRef(null) + const partialRef = useRef(null) + const minimalRef = useRef(null) + + useEffect(() => { + if (!fullRef.current) return + fullRef.current.versions = ['v1.0', 'v2.0', 'v3.0', 'v4.0'] + fullRef.current.platforms = ['Chrome', 'Firefox', 'Safari', 'Node.js'] + fullRef.current.support = { + 'v1.0': { Chrome: 'yes', Firefox: 'yes', Safari: 'partial', 'Node.js': 'no' }, + 'v2.0': { Chrome: 'yes', Firefox: 'yes', Safari: 'yes', 'Node.js': 'partial' }, + 'v3.0': { Chrome: 'yes', Firefox: 'yes', Safari: 'yes', 'Node.js': 'yes' }, + 'v4.0': { Chrome: 'yes', Firefox: 'partial', Safari: 'unknown', 'Node.js': 'yes' }, + } + }, []) + + useEffect(() => { + if (!partialRef.current) return + partialRef.current.versions = ['v0.8', 'v0.9', 'v1.0'] + partialRef.current.platforms = ['Windows', 'macOS', 'Linux', 'iOS', 'Android'] + partialRef.current.support = { + 'v0.8': { + Windows: 'yes', + macOS: 'partial', + Linux: 'no', + iOS: 'no', + Android: 'unknown', + }, + 'v0.9': { Windows: 'yes', macOS: 'yes', Linux: 'partial', iOS: 'no', Android: 'no' }, + 'v1.0': { + Windows: 'yes', + macOS: 'yes', + Linux: 'yes', + iOS: 'partial', + Android: 'partial', + }, + } + }, []) + + useEffect(() => { + if (!minimalRef.current) return + minimalRef.current.versions = ['stable', 'beta'] + minimalRef.current.platforms = ['x86', 'arm64'] + minimalRef.current.support = { + stable: { x86: 'yes', arm64: 'yes' }, + beta: { x86: 'yes', arm64: 'partial' }, + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default CompatibilityMatrixDemo diff --git a/examples/src/web-components/ConfigPreviewDemo.tsx b/examples/src/web-components/ConfigPreviewDemo.tsx new file mode 100644 index 00000000..133bb359 --- /dev/null +++ b/examples/src/web-components/ConfigPreviewDemo.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useRef } from 'react' + +const ENTRIES_BASIC = [ + { key: 'host', value: 'db.internal', comment: 'primary database host' }, + { key: 'port', value: 5432 }, + { key: 'database', value: 'app_production' }, + { key: 'ssl', value: true }, + { key: 'pool_size', value: 10 }, + { key: 'timeout', value: null, comment: 'uses driver default' }, +] + +const ENTRIES_SERVICE = [ + { key: 'name', value: 'api-gateway' }, + { key: 'version', value: '2.1.0' }, + { key: 'port', value: 8080, comment: 'HTTP port' }, + { key: 'debug', value: false }, + { key: 'workers', value: 4 }, + { key: 'secret', value: null, comment: 'injected at runtime' }, +] + +const ConfigPreviewDemo: React.FC = () => { + const basicRef = useRef(null) + const liveRef = useRef(null) + const slotLabelRef = useRef(null) + + useEffect(() => { + if (basicRef.current) basicRef.current.entries = ENTRIES_BASIC + }, []) + + useEffect(() => { + if (liveRef.current) liveRef.current.entries = ENTRIES_SERVICE + }, []) + + useEffect(() => { + if (slotLabelRef.current) slotLabelRef.current.entries = ENTRIES_BASIC + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Connected + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + { + '# Manually authored config\nregion: us-east-1\nzone: us-east-1a' + } + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default ConfigPreviewDemo diff --git a/examples/src/web-components/ConfirmDialogDemo.tsx b/examples/src/web-components/ConfirmDialogDemo.tsx new file mode 100644 index 00000000..3855ebfb --- /dev/null +++ b/examples/src/web-components/ConfirmDialogDemo.tsx @@ -0,0 +1,127 @@ +import React, { useRef, useState, useEffect } from 'react' + +type ConfirmVariant = { + key: string + sectionTitle: string + description: string + dialogTitle: string + eyebrow?: string + message?: string + confirmLabel?: string + cancelLabel?: string + danger?: boolean +} + +const VARIANTS: ConfirmVariant[] = [ + { + key: 'default', + sectionTitle: 'default — primary confirm', + description: + 'Yes/no confirmation. Press Enter to confirm, Escape or the backdrop to cancel.', + dialogTitle: 'Publish this release?', + eyebrow: 'Confirm', + message: 'This will make version 2.4.0 available to everyone. You can roll back later.', + confirmLabel: 'Publish', + }, + { + key: 'danger', + sectionTitle: 'danger — destructive confirm', + description: 'The danger attribute paints the confirm button red and tints the eyebrow.', + dialogTitle: 'Delete this project?', + eyebrow: 'Destructive', + message: 'This permanently removes the project and all of its data. This cannot be undone.', + confirmLabel: 'Delete project', + cancelLabel: 'Keep it', + danger: true, + }, + { + key: 'minimal', + sectionTitle: 'minimal — title only', + description: 'Omit eyebrow and message for a compact prompt with just a title.', + dialogTitle: 'Discard changes?', + eyebrow: '', + confirmLabel: 'Discard', + cancelLabel: 'Cancel', + }, +] + +const ConfirmDialogDemo: React.FC = () => { + const [openKey, setOpenKey] = useState(null) + const [lastResult, setLastResult] = useState('—') + const refs = useRef>({}) + + useEffect(() => { + const cleanups: Array<() => void> = [] + VARIANTS.forEach((v) => { + const el = refs.current[v.key] + if (!el) return + const onConfirm = () => { + setLastResult(`Confirmed: ${v.dialogTitle}`) + setOpenKey(null) + } + const onCancel = () => { + setLastResult(`Cancelled: ${v.dialogTitle}`) + setOpenKey(null) + } + el.addEventListener('tc-confirm', onConfirm) + el.addEventListener('tc-cancel', onCancel) + cleanups.push(() => { + el.removeEventListener('tc-confirm', onConfirm) + el.removeEventListener('tc-cancel', onCancel) + }) + }) + return () => cleanups.forEach((fn) => fn()) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {lastResult} + + + {VARIANTS.map((v) => ( + +

{v.description}

+ + {/* @ts-ignore */} + { + refs.current[v.key] = el + }} + open={openKey === v.key || undefined} + dialog-title={v.dialogTitle} + eyebrow={v.eyebrow} + message={v.message} + confirm-label={v.confirmLabel} + cancel-label={v.cancelLabel} + danger={v.danger || undefined} + /> +
+ ))} +
+
+
+
+
+ ) +} + +export default ConfirmDialogDemo diff --git a/examples/src/web-components/ContainerDemo.tsx b/examples/src/web-components/ContainerDemo.tsx new file mode 100644 index 00000000..7aae48f3 --- /dev/null +++ b/examples/src/web-components/ContainerDemo.tsx @@ -0,0 +1,71 @@ +import React from 'react' + +const Box = ({ label }: { label: string }) => ( +
+ {label} +
+) + +const ContainerDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + +
+ {(['sm', 'md', 'lg', 'xl', 'xxl'] as const).map((bp) => ( + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + ))} +
+
+
+
+
+
+
+) + +export default ContainerDemo diff --git a/examples/src/web-components/ContextMenuDemo.tsx b/examples/src/web-components/ContextMenuDemo.tsx new file mode 100644 index 00000000..eb8c9f66 --- /dev/null +++ b/examples/src/web-components/ContextMenuDemo.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useRef, useState } from 'react' + +const ContextMenuDemo: React.FC = () => { + const basicRef = useRef(null) + const nestedRef = useRef(null) + const [lastKey, setLastKey] = useState(null) + + useEffect(() => { + if (!basicRef.current) return + basicRef.current.items = [ + { key: 'copy', label: 'Copy', icon: 'Copy' }, + { key: 'cut', label: 'Cut', icon: 'Scissors' }, + { key: 'paste', label: 'Paste', icon: 'Clipboard', disabled: true }, + { key: 'sep-1', label: '', separator: true }, + { key: 'delete', label: 'Delete', icon: 'Trash2', danger: true }, + ] + const handler = (e: Event) => { + setLastKey((e as CustomEvent<{ key: string }>).detail.key) + } + basicRef.current.addEventListener('tc-select', handler) + return () => basicRef.current?.removeEventListener('tc-select', handler) + }, []) + + useEffect(() => { + if (!nestedRef.current) return + nestedRef.current.items = [ + { key: 'open', label: 'Open', icon: 'FolderOpen' }, + { + key: 'share', + label: 'Share', + icon: 'Share2', + children: [ + { key: 'share-link', label: 'Copy link', icon: 'Link' }, + { key: 'share-email', label: 'Send by email', icon: 'Mail' }, + { key: 'share-sep', label: '', separator: true }, + { key: 'share-export', label: 'Export…', icon: 'Download' }, + ], + }, + { key: 'rename', label: 'Rename', icon: 'Pencil' }, + { key: 'sep-2', label: '', separator: true }, + { key: 'remove', label: 'Remove', icon: 'Trash2', danger: true }, + ] + const handler = (e: Event) => { + setLastKey((e as CustomEvent<{ key: string }>).detail.key) + } + nestedRef.current.addEventListener('tc-select', handler) + return () => nestedRef.current?.removeEventListener('tc-select', handler) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + + {lastKey && ( +
+ Last selected key: {lastKey} +
+ )} + +
+ +

+ Right-click (or long-press on touch) to open the context menu. + Keyboard: Arrow keys to navigate, Enter/Space to select, Escape + to close. +

+ {/* @ts-ignore */} + +
+ Right-click here +
+
+
+ + +

+ The Share item has a nested submenu. Hover or press ArrowRight + to open it. +

+ {/* @ts-ignore */} + +
+ Right-click here (nested submenu) +
+
+
+
+
+
+
+
+ ) +} + +export default ContextMenuDemo diff --git a/examples/src/web-components/ContributorWallDemo.tsx b/examples/src/web-components/ContributorWallDemo.tsx new file mode 100644 index 00000000..6c13294f --- /dev/null +++ b/examples/src/web-components/ContributorWallDemo.tsx @@ -0,0 +1,196 @@ +import React, { useEffect, useRef } from 'react' + +const CONTRIBUTORS_WITH_AVATARS = [ + { + name: 'Alice Martin', + avatarUrl: 'https://i.pravatar.cc/80?img=1', + profileUrl: '#', + contributions: 142, + }, + { + name: 'Bob Chen', + avatarUrl: 'https://i.pravatar.cc/80?img=2', + profileUrl: '#', + contributions: 98, + }, + { + name: 'Chloe Dupont', + avatarUrl: 'https://i.pravatar.cc/80?img=3', + profileUrl: '#', + contributions: 74, + }, + { + name: 'David Osei', + avatarUrl: 'https://i.pravatar.cc/80?img=4', + profileUrl: '#', + contributions: 61, + }, + { + name: 'Eva Rossi', + avatarUrl: 'https://i.pravatar.cc/80?img=5', + profileUrl: '#', + contributions: 43, + }, +] + +const CONTRIBUTORS_MIXED = [ + { + name: 'Alice Martin', + avatarUrl: 'https://i.pravatar.cc/80?img=1', + profileUrl: '#', + contributions: 142, + }, + { name: 'Bob Chen', profileUrl: '#', contributions: 98 }, + { + name: 'Chloe Dupont', + avatarUrl: 'https://i.pravatar.cc/80?img=3', + profileUrl: '#', + contributions: 74, + }, + { name: 'David Osei', profileUrl: '#', contributions: 61 }, + { + name: 'Eva Rossi', + avatarUrl: 'https://i.pravatar.cc/80?img=5', + profileUrl: '#', + contributions: 43, + }, + { name: 'Frank Müller', profileUrl: '#', contributions: 38 }, + { + name: 'Grace Kim', + avatarUrl: 'https://i.pravatar.cc/80?img=7', + profileUrl: '#', + contributions: 29, + }, + { name: 'Hiro Tanaka', profileUrl: '#', contributions: 17 }, +] + +const CONTRIBUTORS_OVERFLOW = [ + { + name: 'Alice Martin', + avatarUrl: 'https://i.pravatar.cc/80?img=1', + profileUrl: '#', + contributions: 142, + }, + { name: 'Bob Chen', profileUrl: '#', contributions: 98 }, + { + name: 'Chloe Dupont', + avatarUrl: 'https://i.pravatar.cc/80?img=3', + profileUrl: '#', + contributions: 74, + }, + { name: 'David Osei', profileUrl: '#', contributions: 61 }, + { + name: 'Eva Rossi', + avatarUrl: 'https://i.pravatar.cc/80?img=5', + profileUrl: '#', + contributions: 43, + }, + { name: 'Frank Müller', profileUrl: '#', contributions: 38 }, + { + name: 'Grace Kim', + avatarUrl: 'https://i.pravatar.cc/80?img=7', + profileUrl: '#', + contributions: 29, + }, + { name: 'Hiro Tanaka', profileUrl: '#', contributions: 17 }, + { name: 'Isla Brown', profileUrl: '#', contributions: 12 }, + { + name: 'Jake Wilson', + avatarUrl: 'https://i.pravatar.cc/80?img=10', + profileUrl: '#', + contributions: 8, + }, + { name: 'Karen Lee', profileUrl: '#', contributions: 5 }, + { name: 'Leo Santos', profileUrl: '#', contributions: 3 }, +] + +const CONTRIBUTORS_NO_LINKS = [ + { name: 'Alice Martin', avatarUrl: 'https://i.pravatar.cc/80?img=1', contributions: 142 }, + { name: 'Bob Chen', contributions: 98 }, + { name: 'Chloe Dupont', avatarUrl: 'https://i.pravatar.cc/80?img=3', contributions: 74 }, + { name: 'David Osei', contributions: 61 }, +] + +const ContributorWallDemo: React.FC = () => { + const avatarsRef = useRef(null) + const mixedRef = useRef(null) + const overflowRef = useRef(null) + const noLinksRef = useRef(null) + const slotTitleRef = useRef(null) + + useEffect(() => { + if (avatarsRef.current) avatarsRef.current.contributors = CONTRIBUTORS_WITH_AVATARS + }, []) + + useEffect(() => { + if (mixedRef.current) mixedRef.current.contributors = CONTRIBUTORS_MIXED + }, []) + + useEffect(() => { + if (overflowRef.current) overflowRef.current.contributors = CONTRIBUTORS_OVERFLOW + }, []) + + useEffect(() => { + if (noLinksRef.current) noLinksRef.current.contributors = CONTRIBUTORS_NO_LINKS + }, []) + + useEffect(() => { + if (slotTitleRef.current) slotTitleRef.current.contributors = CONTRIBUTORS_MIXED + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + Slotted heading + + + +
+
+
+
+
+ ) +} + +export default ContributorWallDemo diff --git a/examples/src/web-components/ControllerLayoutPreviewDemo.tsx b/examples/src/web-components/ControllerLayoutPreviewDemo.tsx new file mode 100644 index 00000000..5e87fe99 --- /dev/null +++ b/examples/src/web-components/ControllerLayoutPreviewDemo.tsx @@ -0,0 +1,68 @@ +import React, { useEffect, useRef, useState } from 'react' + +const LAYOUTS = ['generic', 'xbox', 'playstation', 'nintendo'] as const + +const ControllerLayoutPreviewDemo: React.FC = () => { + const liveRef = useRef(null) + const [idx, setIdx] = useState(0) + + // Cycle through the four layouts so the face-button bindings relabel live. + useEffect(() => { + const id = window.setInterval(() => { + setIdx((i) => (i + 1) % LAYOUTS.length) + }, 1800) + return () => window.clearInterval(id) + }, []) + + useEffect(() => { + if (liveRef.current) liveRef.current.layout = LAYOUTS[idx] + }, [idx]) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default ControllerLayoutPreviewDemo diff --git a/examples/src/web-components/ControlsRebindListDemo.tsx b/examples/src/web-components/ControlsRebindListDemo.tsx new file mode 100644 index 00000000..44d9cd1c --- /dev/null +++ b/examples/src/web-components/ControlsRebindListDemo.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useRef, useState } from 'react' + +const ControlsRebindListDemo: React.FC = () => { + const basicRef = useRef(null) + const interactiveRef = useRef(null) + const [lastRebound, setLastRebound] = useState(null) + + useEffect(() => { + if (basicRef.current) { + basicRef.current.bindings = [ + { id: 'move-up', action: 'Move Up', key: 'W' }, + { id: 'move-down', action: 'Move Down', key: 'S' }, + { id: 'move-left', action: 'Move Left', key: 'A' }, + { id: 'move-right', action: 'Move Right', key: 'D' }, + { id: 'jump', action: 'Jump', key: 'Space' }, + { id: 'crouch', action: 'Crouch' }, + ] + } + }, []) + + useEffect(() => { + const el = interactiveRef.current + if (!el) return + + el.bindings = [ + { id: 'fire', action: 'Fire', key: 'Mouse 1' }, + { id: 'reload', action: 'Reload', key: 'R' }, + { id: 'interact', action: 'Interact', key: 'E' }, + { id: 'melee', action: 'Melee' }, + ] + + const handler = (e: CustomEvent<{ id: string }>) => { + setLastRebound(e.detail.id) + console.log('[tc-controls-rebind-list] tc-rebind:', e.detail.id) + } + el.addEventListener('tc-rebind', handler as EventListener) + return () => el.removeEventListener('tc-rebind', handler as EventListener) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +

+ Last rebind requested:{' '} + {lastRebound ?? '— none yet —'} +

+
+
+
+
+
+
+ ) +} + +export default ControlsRebindListDemo diff --git a/examples/src/web-components/CookbookGridDemo.tsx b/examples/src/web-components/CookbookGridDemo.tsx new file mode 100644 index 00000000..0c68f6de --- /dev/null +++ b/examples/src/web-components/CookbookGridDemo.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useRef } from 'react' + +const RECIPES_2COL = [ + { + title: 'Debounce a function', + description: 'Delay execution until after a quiet period.', + code: `function debounce(fn, delay) {\n let id\n return (...args) => {\n clearTimeout(id)\n id = setTimeout(() => fn(...args), delay)\n }\n}`, + language: 'javascript', + tags: ['utility', 'async'], + href: '#debounce', + }, + { + title: 'Deep clone an object', + description: 'Create a structurally independent copy without references.', + code: `const clone = obj => JSON.parse(JSON.stringify(obj))`, + language: 'javascript', + tags: ['utility'], + href: '#clone', + }, + { + title: 'Flatten a nested array', + description: 'Recursively flatten arrays of arbitrary depth.', + code: `const flat = arr =>\n arr.reduce((acc, v) =>\n acc.concat(Array.isArray(v) ? flat(v) : v), [])`, + language: 'javascript', + tags: ['array', 'utility'], + }, + { + title: 'Group array by key', + description: 'Group an array of objects by a shared property.', + code: `const groupBy = (arr, key) =>\n arr.reduce((acc, item) => {\n const g = item[key]\n ;(acc[g] = acc[g] || []).push(item)\n return acc\n }, {})`, + language: 'javascript', + tags: ['array', 'object'], + }, +] + +const RECIPES_3COL = [ + { + title: 'Sleep / delay', + description: 'Promise-based delay helper.', + code: `const sleep = ms =>\n new Promise(r => setTimeout(r, ms))`, + language: 'typescript', + tags: ['async'], + }, + { + title: 'Clamp a number', + code: `const clamp = (n, min, max) =>\n Math.min(Math.max(n, min), max)`, + language: 'typescript', + tags: ['math'], + }, + { + title: 'Unique array items', + description: 'Remove duplicate values from an array.', + code: `const unique = arr => [...new Set(arr)]`, + language: 'typescript', + tags: ['array'], + }, + { + title: 'Pick object keys', + code: `const pick = (obj, keys) =>\n Object.fromEntries(\n keys.map(k => [k, obj[k]])\n )`, + language: 'typescript', + tags: ['object'], + }, + { + title: 'Chunk an array', + description: 'Split array into fixed-size chunks.', + code: `const chunk = (arr, n) =>\n Array.from({ length: Math.ceil(arr.length / n) },\n (_, i) => arr.slice(i * n, i * n + n))`, + language: 'typescript', + tags: ['array'], + }, + { + title: 'Omit object keys', + code: `const omit = (obj, keys) =>\n Object.fromEntries(\n Object.entries(obj).filter(([k]) => !keys.includes(k))\n )`, + language: 'typescript', + tags: ['object'], + }, +] + +const CookbookGridDemo: React.FC = () => { + const twoColRef = useRef(null) + const threeColRef = useRef(null) + const noTitleRef = useRef(null) + const slotTitleRef = useRef(null) + + useEffect(() => { + if (twoColRef.current) twoColRef.current.recipes = RECIPES_2COL + }, []) + + useEffect(() => { + if (threeColRef.current) threeColRef.current.recipes = RECIPES_3COL + }, []) + + useEffect(() => { + if (noTitleRef.current) noTitleRef.current.recipes = RECIPES_2COL.slice(0, 2) + }, []) + + useEffect(() => { + if (slotTitleRef.current) slotTitleRef.current.recipes = RECIPES_2COL.slice(0, 2) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + Slotted heading + + + +
+
+
+
+
+ ) +} + +export default CookbookGridDemo diff --git a/examples/src/web-components/CoolButtonDemo.tsx b/examples/src/web-components/CoolButtonDemo.tsx new file mode 100644 index 00000000..fa4c3da3 --- /dev/null +++ b/examples/src/web-components/CoolButtonDemo.tsx @@ -0,0 +1,375 @@ +import React, { useEffect, useRef, useState } from 'react' + +const CoolButtonDemo: React.FC = () => { + const [clicked, setClicked] = useState('') + const [busy, setBusy] = useState(false) + const loadingRef = useRef(null) + const toggleRef = useRef(null) + const addonLeftRef = useRef(null) + const addonRightSlotRef = useRef(null) + + useEffect(() => { + const el = loadingRef.current + if (!el) return + el.addEventListener('tc-click', () => setClicked('loading button clicked')) + }, []) + + // Toggle a real loading cycle so the stable-width behaviour is visible: + // the button must not grow, shrink, or jump when `loading` flips on/off. + useEffect(() => { + const el = toggleRef.current + if (!el) return + const handler = () => { + setBusy(true) + window.setTimeout(() => setBusy(false), 1600) + } + el.addEventListener('tc-click', handler) + return () => el.removeEventListener('tc-click', handler) + }, []) + + useEffect(() => { + if (toggleRef.current) toggleRef.current.loading = busy + }, [busy]) + + useEffect(() => { + const el = addonLeftRef.current + if (!el) return + el.addEventListener('tc-click', () => setClicked('addon-left button clicked')) + }, []) + + useEffect(() => { + const el = addonRightSlotRef.current + if (!el) return + el.addEventListener('tc-click', () => setClicked('addon-right slot button clicked')) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + + {clicked && ( +
+ Event: {clicked} +
+ )} + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +

+ The spinner is centred over the label and sized to the text. The + label is hidden (not removed), so the button keeps its resting + width and stays non-interactive. +

+
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +

+ Click to start a 1.6s task. Watch the button: it does not resize + or shift when the spinner appears or disappears. +

+
+ {/* @ts-ignore */} + + + {busy ? 'working…' : 'idle'} + +
+
+ + +

+ With a leading addon the spinner replaces the icon glyph; with a + trailing addon it overlays the label and the addon glyph stays + put. +

+
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +

+ Addon glyphs are sized to the label and vertically centred + against it, with a consistent gap and a 1px divider. +

+
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +

+ Slotted addon content is centred and aligned to the label too — + here a mono version tag and a glyph that inherits the label + sizing. +

+
+ {/* @ts-ignore */} + + Publish + {/* @ts-ignore */} + + v2 + + + {/* @ts-ignore */} + + Share + {/* @ts-ignore */} + + +
+
+ + +
+ {/* @ts-ignore */} + + Bold label + + {/* @ts-ignore */} + + Italic label + +
+
+
+
+
+
+
+ ) +} + +export default CoolButtonDemo diff --git a/examples/src/web-components/CoolNavDemo.tsx b/examples/src/web-components/CoolNavDemo.tsx new file mode 100644 index 00000000..968ce963 --- /dev/null +++ b/examples/src/web-components/CoolNavDemo.tsx @@ -0,0 +1,190 @@ +import React, { useEffect, useRef, useState } from 'react' + +const NAV_ITEMS = [ + { label: 'Home', href: '#home', active: true }, + { label: 'Docs', href: '#docs' }, + { label: 'Examples', href: '#examples' }, + { label: 'Pricing', href: '#pricing' }, +] + +const CoolNavDemo: React.FC = () => { + const [lastEvent, setLastEvent] = useState('') + + const basicRef = useRef(null) + const darkRef = useRef(null) + const stickyRef = useRef(null) + const noLoginRef = useRef(null) + const customBpRef = useRef(null) + + useEffect(() => { + const el = basicRef.current + if (!el) return + el.items = NAV_ITEMS + el.addEventListener('tc-nav-toggle', (e: CustomEvent) => { + setLastEvent(`tc-nav-toggle: open=${e.detail.open}`) + }) + el.addEventListener('tc-login', () => { + setLastEvent('tc-login fired') + }) + }, []) + + useEffect(() => { + if (darkRef.current) { + darkRef.current.items = NAV_ITEMS + } + }, []) + + useEffect(() => { + if (stickyRef.current) { + stickyRef.current.items = NAV_ITEMS + stickyRef.current.classList.add('tc-cool-nav-scrolled') + } + }, []) + + useEffect(() => { + if (noLoginRef.current) { + noLoginRef.current.items = NAV_ITEMS.slice(0, 3) + } + }, []) + + useEffect(() => { + if (customBpRef.current) { + customBpRef.current.items = NAV_ITEMS + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + + {lastEvent && ( +
+ Event: {lastEvent} +
+ )} + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + myapp + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +

+ With sticky, the nav is{' '} + position: sticky; top: 0. The scrolled (condensed + + shadow) state is applied via classList.add in the + effect for preview. +

+
+ + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default CoolNavDemo diff --git a/examples/src/web-components/CooldownBadgeDemo.tsx b/examples/src/web-components/CooldownBadgeDemo.tsx new file mode 100644 index 00000000..2bbf00ac --- /dev/null +++ b/examples/src/web-components/CooldownBadgeDemo.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useRef, useState } from 'react' + +const CooldownBadgeDemo: React.FC = () => { + // Live cooldown: `value` climbs from 0 to `max`, depleting the ring; when it + // reaches `max` the badge flips to its ready state, then we reset. + const liveRef = useRef(null) + const [value, setValue] = useState(0) + const MAX = 8 + + useEffect(() => { + const id = setInterval(() => { + setValue((v) => (v >= MAX ? 0 : +(v + 0.1).toFixed(1))) + }, 100) + return () => clearInterval(id) + }, []) + + useEffect(() => { + if (liveRef.current) liveRef.current.value = value + }, [value]) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default CooldownBadgeDemo diff --git a/examples/src/web-components/CountdownTimerDemo.tsx b/examples/src/web-components/CountdownTimerDemo.tsx new file mode 100644 index 00000000..a98526a5 --- /dev/null +++ b/examples/src/web-components/CountdownTimerDemo.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useRef, useMemo, useState } from 'react' + +const CountdownTimerDemo: React.FC = () => { + const now = useMemo(() => Date.now(), []) + const baseTarget = String(now + 5 * 60 * 1000 + 30 * 1000) // 5 min 30 sec from load + + const customRef = useRef(null) + const expireRef = useRef(null) + const [expireFired, setExpireFired] = useState(false) + + useEffect(() => { + const el = customRef.current + if (!el) return + el.units = ['hours', 'minutes', 'seconds'] + el.target = new Date(now + 2 * 3600 * 1000) + }, []) + + useEffect(() => { + const el = expireRef.current + if (!el) return + // target 3 seconds from mount so the expire event fires quickly in the demo + el.target = new Date(now + 3000) + const handler = () => setExpireFired(true) + el.addEventListener('tc-expire', handler) + return () => el.removeEventListener('tc-expire', handler) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {expireFired && ( +
+ ✓ tc-expire event fired — timer has expired. +
+ )} +
+
+
+
+
+
+ ) +} + +export default CountdownTimerDemo diff --git a/examples/src/web-components/CraftingPanelDemo.tsx b/examples/src/web-components/CraftingPanelDemo.tsx new file mode 100644 index 00000000..b653e7db --- /dev/null +++ b/examples/src/web-components/CraftingPanelDemo.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useRef, useState } from 'react' + +const RECIPES = [ + { + id: 'iron-sword', + name: 'Iron Sword', + icon: 'IS', + inputs: [ + { item: { id: 'iron', name: 'Iron Ingot', icon: 'Fe' }, qty: 2, available: 5 }, + { item: { id: 'wood', name: 'Oak Plank', icon: 'W' }, qty: 1, available: 3 }, + ], + output: { item: { id: 'iron-sword', name: 'Iron Sword', icon: 'IS' }, qty: 1 }, + }, + { + id: 'health-potion', + name: 'Health Potion', + icon: 'HP', + inputs: [ + { item: { id: 'herb', name: 'Red Herb', icon: 'H' }, qty: 3, available: 1 }, + { item: { id: 'vial', name: 'Glass Vial', icon: 'V' }, qty: 1, available: 4 }, + ], + output: { item: { id: 'health-potion', name: 'Health Potion', icon: 'HP' }, qty: 2 }, + }, + { + id: 'steel-shield', + name: 'Steel Shield', + icon: 'SS', + inputs: [ + { item: { id: 'steel', name: 'Steel Plate', icon: 'St' }, qty: 4, available: 6 }, + { item: { id: 'leather', name: 'Leather Strap', icon: 'L' }, qty: 2, available: 2 }, + ], + output: { item: { id: 'steel-shield', name: 'Steel Shield', icon: 'SS' }, qty: 1 }, + }, +] + +const CraftingPanelDemo: React.FC = () => { + const basicRef = useRef(null) + const interactiveRef = useRef(null) + const [selected, setSelected] = useState(null) + const [crafted, setCrafted] = useState(null) + + useEffect(() => { + if (basicRef.current) { + basicRef.current.recipes = RECIPES + basicRef.current.selectedId = 'iron-sword' + } + }, []) + + useEffect(() => { + const el = interactiveRef.current + if (!el) return + + el.recipes = RECIPES + + const onSelect = (e: CustomEvent<{ id: string }>) => { + setSelected(e.detail.id) + console.log('[tc-crafting-panel] tc-select:', e.detail.id) + } + const onCraft = (e: CustomEvent<{ id: string }>) => { + setCrafted(e.detail.id) + console.log('[tc-crafting-panel] tc-craft:', e.detail.id) + // Simulate a brief crafting cycle. + el.crafting = true + window.setTimeout(() => { + el.crafting = false + }, 900) + } + el.addEventListener('tc-select', onSelect as EventListener) + el.addEventListener('tc-craft', onCraft as EventListener) + return () => { + el.removeEventListener('tc-select', onSelect as EventListener) + el.removeEventListener('tc-craft', onCraft as EventListener) + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +

+ Selected: {selected ?? '— none yet —'} + {' · '} + Last crafted: {crafted ?? '— none yet —'} +

+
+
+
+
+
+
+ ) +} + +export default CraftingPanelDemo diff --git a/examples/src/web-components/CreditsScrollDemo.tsx b/examples/src/web-components/CreditsScrollDemo.tsx new file mode 100644 index 00000000..2727b3b9 --- /dev/null +++ b/examples/src/web-components/CreditsScrollDemo.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useRef, useState } from 'react' + +const SECTIONS = [ + { role: 'Direction', names: ['Aldric Vane', 'Brina Storm'] }, + { role: 'Engineering', names: ['Caelum Brook', 'Dorin Hale', 'Eira Wynne'] }, + { role: 'Art', names: ['Faelyn Reed', 'Garrick Ash'] }, + { role: 'Sound', names: ['Hesper Lane'] }, + { role: 'Special Thanks', names: ['Ironwood Studio', 'The Cartographers Guild'] }, +] + +const CreditsScrollDemo: React.FC = () => { + const ref = useRef(null) + const fastRef = useRef(null) + const [status, setStatus] = useState('scrolling…') + + useEffect(() => { + const el = ref.current + if (!el) return + el.sections = SECTIONS + const onComplete = () => setStatus('complete') + el.addEventListener('tc-complete', onComplete) + return () => el.removeEventListener('tc-complete', onComplete) + }, []) + + useEffect(() => { + const el = fastRef.current + if (!el) return + el.sections = SECTIONS + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default CreditsScrollDemo diff --git a/examples/src/web-components/CrosshairDemo.tsx b/examples/src/web-components/CrosshairDemo.tsx new file mode 100644 index 00000000..4b6f5ec7 --- /dev/null +++ b/examples/src/web-components/CrosshairDemo.tsx @@ -0,0 +1,124 @@ +import React from 'react' + +const CrosshairDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+
+
+
+
+
+) + +export default CrosshairDemo diff --git a/examples/src/web-components/CurrencyChipDemo.tsx b/examples/src/web-components/CurrencyChipDemo.tsx new file mode 100644 index 00000000..fe63ca28 --- /dev/null +++ b/examples/src/web-components/CurrencyChipDemo.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useRef } from 'react' + +const CurrencyChipDemo: React.FC = () => { + const chipRef = useRef(null) + + // JS-property props (amount as a real number) need a ref — React can't set + // them as attributes. Demonstrates the reflected numeric `amount` property. + useEffect(() => { + const el = chipRef.current + if (!el) return + el.glyph = '◆' + el.amount = 1250000 + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default CurrencyChipDemo diff --git a/examples/src/web-components/CurrencyDisplayDemo.tsx b/examples/src/web-components/CurrencyDisplayDemo.tsx new file mode 100644 index 00000000..419e3236 --- /dev/null +++ b/examples/src/web-components/CurrencyDisplayDemo.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useRef } from 'react' + +const CurrencyDisplayDemo: React.FC = () => { + const displayRef = useRef(null) + + // JS-property props (amount as a real number, font-size as a number) need a + // ref — React can't set them as attributes. Demonstrates the reflected + // numeric `amount` and `fontSize` properties plus `label`/`currencyIcon`. + useEffect(() => { + const el = displayRef.current + if (!el) return + el.label = 'Wallet balance' + el.currencyIcon = '◆' + el.amount = 1284500 + el.fontSize = 32 + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default CurrencyDisplayDemo diff --git a/examples/src/web-components/CycleWheelDemo.tsx b/examples/src/web-components/CycleWheelDemo.tsx new file mode 100644 index 00000000..1cd3a343 --- /dev/null +++ b/examples/src/web-components/CycleWheelDemo.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useRef, useState } from 'react' + +const CycleWheelDemo: React.FC = () => { + const mainRef = useRef(null) + const threeRef = useRef(null) + const pausedRef = useRef(null) + + const phases = ['Roll', 'Build', 'Attest', 'Ship', 'Close'] + const [current, setCurrent] = useState(1) + const [paused, setPaused] = useState(false) + + // phases is a JS property (array of strings) — must be set via ref, not attribute. + useEffect(() => { + if (mainRef.current) mainRef.current.phases = phases + }, []) + + useEffect(() => { + if (threeRef.current) threeRef.current.phases = ['Draft', 'Review', 'Ship'] + }, []) + + useEffect(() => { + if (pausedRef.current) pausedRef.current.phases = phases + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+
+ + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+ ) +} + +export default CycleWheelDemo diff --git a/examples/src/web-components/DamageNumberDemo.tsx b/examples/src/web-components/DamageNumberDemo.tsx new file mode 100644 index 00000000..e399581f --- /dev/null +++ b/examples/src/web-components/DamageNumberDemo.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useRef, useState } from 'react' + +const DamageNumberDemo: React.FC = () => { + // The numbers play their rise-and-fade once then sit at opacity 0; bumping + // this key remounts the elements so the animation can be watched again. + const [replayKey, setReplayKey] = useState(0) + + const eventRef = useRef(null) + const [doneCount, setDoneCount] = useState(0) + + useEffect(() => { + const el = eventRef.current + if (!el) return + const handler = () => setDoneCount((c) => c + 1) + el.addEventListener('tc-done', handler) + return () => el.removeEventListener('tc-done', handler) + }, [replayKey]) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ Normal · crit (larger, danger red) ·{' '} + heal (green, + prefix) ·{' '} + miss (muted uppercase). +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ {doneCount > 0 ? ( + + ✓ tc-done fired {doneCount}× — the number + finished its animation. + + ) : ( + + Waiting for tc-done (fires after 2 s)… + + )} +
+
+
+
+
+
+
+ ) +} + +export default DamageNumberDemo diff --git a/examples/src/web-components/DangerZoneActionsDemo.tsx b/examples/src/web-components/DangerZoneActionsDemo.tsx new file mode 100644 index 00000000..7b390800 --- /dev/null +++ b/examples/src/web-components/DangerZoneActionsDemo.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useRef } from 'react' + +const DangerZoneActionsDemo: React.FC = () => { + const basicRef = useRef(null) + const withIconsRef = useRef(null) + const withDisabledRef = useRef(null) + + useEffect(() => { + if (basicRef.current) { + basicRef.current.actions = [ + { + key: 'delete-account', + title: 'Delete account', + description: + 'Permanently remove your account and all associated data. This action cannot be undone.', + buttonLabel: 'Delete account', + }, + { + key: 'reset-data', + title: 'Reset all data', + description: + 'Wipe all project data and start fresh. Your plan and billing will remain active.', + buttonLabel: 'Reset data', + }, + ] + } + }, []) + + useEffect(() => { + if (withIconsRef.current) { + withIconsRef.current.actions = [ + { + key: 'revoke-tokens', + title: 'Revoke all tokens', + description: + 'Immediately invalidate all active API tokens. Integrations using these tokens will stop working.', + buttonLabel: 'Revoke tokens', + icon: 'KeyRound', + }, + { + key: 'delete-workspace', + title: 'Delete workspace', + description: + 'Remove this workspace and all its members, projects, and resources permanently.', + buttonLabel: 'Delete workspace', + icon: 'Trash2', + }, + ] + } + }, []) + + useEffect(() => { + if (withDisabledRef.current) { + withDisabledRef.current.actions = [ + { + key: 'archive-project', + title: 'Archive project', + description: + 'Move this project to the archive. It will become read-only and hidden from the main list.', + buttonLabel: 'Archive', + icon: 'Archive', + }, + { + key: 'delete-project', + title: 'Delete project', + description: + 'Permanently delete this project and all its resources. You must archive it first.', + buttonLabel: 'Delete project', + icon: 'Trash2', + disabled: true, + }, + ] + + withDisabledRef.current.addEventListener('tc-action-click', (e: CustomEvent) => { + console.log('[tc-danger-zone-actions] tc-action-click:', e.detail.key) + }) + } + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
+
+
+
+
+ ) +} + +export default DangerZoneActionsDemo diff --git a/examples/src/web-components/DashboardContentDemo.tsx b/examples/src/web-components/DashboardContentDemo.tsx new file mode 100644 index 00000000..7217b231 --- /dev/null +++ b/examples/src/web-components/DashboardContentDemo.tsx @@ -0,0 +1,118 @@ +import React from 'react' + +const DashboardContentDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+ Overview +
+
+
+ Card A +
+
+ Card B +
+
+ Card C +
+
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + + {Array.from({ length: 8 }, (_, i) => ( +
+ Row {i + 1} — dashboard content item +
+ ))} + {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ Content with extra padding applied via{' '} + --bs-dashboard-content-padding. +
+ {/* @ts-ignore */} +
+
+
+
+
+
+
+
+) + +export default DashboardContentDemo diff --git a/examples/src/web-components/DashboardLayoutDemo.tsx b/examples/src/web-components/DashboardLayoutDemo.tsx new file mode 100644 index 00000000..b4a67cc3 --- /dev/null +++ b/examples/src/web-components/DashboardLayoutDemo.tsx @@ -0,0 +1,281 @@ +import React, { useEffect, useRef, useState } from 'react' + +const menuItems = ['Dashboard', 'Projects', 'Analytics', 'Team', 'Settings'] + +const DashboardLayoutDemo: React.FC = () => { + const ref = useRef(null) + const [lastEvent, setLastEvent] = useState('') + + useEffect(() => { + const el = ref.current + if (!el) return + const handler = (e: CustomEvent) => { + setLastEvent(`tc-toggle-sidebar → open: ${e.detail.open}`) + } + el.addEventListener('tc-toggle-sidebar', handler) + return () => el.removeEventListener('tc-toggle-sidebar', handler) + }, []) + + return ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + + {/* brand slot */} +
+ + + MyApp + +
+ {/* sidebar-menu slot */} + + {/* sidebar-panel slot */} +
+ user@example.com +
+ {/* navbar-left slot */} + + / Dashboard + + {/* navbar-right slot */} +
+ + v2.0.0 + +
+ {/* default content */} +
+
+ Main Content +
+

+ Resize below 992px: the sidebar becomes a slide-in + drawer. Use the toggle, Ctrl+B, Esc, or tap the + backdrop to close it. +

+ {lastEvent && ( +
+ {lastEvent} +
+ )} +
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + + +
+ Actions +
+
+ Sidebar with menu only — brand and panel slots omitted. +
+ {/* @ts-ignore */} +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ App +
+ +
+ Sidebar narrowed to 10rem. +
+ {/* @ts-ignore */} +
+
+
+
+
+
+
+
+ ) +} + +export default DashboardLayoutDemo diff --git a/examples/src/web-components/DashboardSidebarDemo.tsx b/examples/src/web-components/DashboardSidebarDemo.tsx new file mode 100644 index 00000000..b383d1f2 --- /dev/null +++ b/examples/src/web-components/DashboardSidebarDemo.tsx @@ -0,0 +1,212 @@ +import React from 'react' + +const DashboardSidebarDemo: React.FC = () => ( +
+
+
+
+ + + Web Components + + + +
+ +
+ {/* @ts-ignore */} + +
+ + MyApp +
+ +
+ user@example.com +
+ {/* @ts-ignore */} +
+
+ Main content area +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ Brand Only +
+ {/* @ts-ignore */} +
+
+ Content +
+
+
+ + +
+ {/* @ts-ignore */} + +
+ Narrow +
+ +
+ v2.0.0 +
+ {/* @ts-ignore */} +
+
+ Content with narrowed sidebar (11rem) +
+
+
+
+
+
+
+
+) + +export default DashboardSidebarDemo diff --git a/examples/src/web-components/DataListDemo.tsx b/examples/src/web-components/DataListDemo.tsx new file mode 100644 index 00000000..8156b975 --- /dev/null +++ b/examples/src/web-components/DataListDemo.tsx @@ -0,0 +1,393 @@ +import React, { useEffect, useRef, useState } from 'react' + +// tc-data-list is the generic, data-driven row list. Domain rendering lives at +// the call site via the `renderRow` hook — the four examples below reproduce the +// former tc-mute-list / tc-team-list / tc-credits-list / tc-achievement-list +// purely as renderRow functions over a single shared element. + +function esc(s: unknown): string { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function deriveInitials(name: string): string { + const w = name.trim().split(/\s+/).filter(Boolean) + if (w.length === 0) return '?' + if (w.length === 1) return w[0][0].toUpperCase() + return (w[0][0] + w[1][0]).toUpperCase() +} + +const MUTED = [ + { id: '1', name: 'ToxicWizard92', reason: 'Spam', mutedAt: '2d ago' }, + { id: '2', name: 'ChatBot_AFK', mutedAt: '1w ago' }, + { id: '3', name: 'Griefer404', reason: 'Harassment', mutedAt: '1mo ago' }, +] + +const TEAM = [ + { id: '1', name: 'Kate Moore', email: 'kate@example.com', role: 'Lead' }, + { id: '2', name: 'Liam Scott', initials: 'LS', email: 'liam@example.com', role: 'Backend' }, + { + id: '3', + name: 'Mia Chen', + email: 'mia@example.com', + avatarUrl: 'https://i.pravatar.cc/64?img=9', + role: 'Frontend', + }, + { id: '4', name: 'Noah Patel', email: 'noah@example.com', role: 'DevOps' }, +] + +const CREDITS = [ + { role: 'Game Director', names: ['Mira Calloway'] }, + { role: 'Lead Engineering', names: ['Tomas Reyes', 'Aiko Nakamura'] }, + { role: 'Art & Animation', names: ['Priya Anand', 'Lukas Berg', 'Sofia Marek'] }, + { role: 'Sound Design', names: ['Daniel Cho'] }, +] + +const ACHIEVEMENTS = [ + { + id: 'first-blood', + name: 'First Blood', + description: 'Defeat your first enemy.', + unlocked: true, + points: 10, + }, + { + id: 'explorer', + name: 'Explorer', + description: 'Discover 25 hidden locations.', + progress: 18, + target: 25, + points: 50, + }, + { + id: 'collector', + name: 'Collector', + description: 'Gather every rare artifact.', + progress: 7, + target: 40, + points: 100, + }, + { + id: 'untouchable', + name: 'Untouchable', + description: 'Finish a boss without taking damage.', + unlocked: false, + points: 75, + }, +] + +const STATUS_COLOR: Record = { + 'in-game': '#a855f7', + online: '#16a34a', + busy: '#dc2626', + away: '#d97706', + offline: 'var(--tc-border-strong)', +} +const STATUS_ORDER = ['in-game', 'online', 'busy', 'away', 'offline'] + +const FRIENDS = [ + { id: '1', name: 'Aria', status: 'online' }, + { id: '2', name: 'Kestrel', status: 'in-game', activity: 'Ranked — Round 3' }, + { id: '3', name: 'Vesper', status: 'busy', activity: 'Do not disturb' }, + { id: '4', name: 'Lumen', status: 'away', activity: 'Idle 12m', rank: 'Gold' }, + { id: '5', name: 'Onyx', status: 'offline' }, +].sort((a, b) => { + const d = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) + return d !== 0 ? d : a.name.localeCompare(b.name) +}) + +const SLOTS = [ + { id: 'auto', name: 'Auto Save', meta: 'Lv 24 · Ember Keep · 12h 04m', autosave: true }, + { id: 's1', name: 'The Long Road', meta: 'Lv 22 · Vale of Mist · 11h 02m' }, + { id: 's2', name: 'Before the Boss', meta: 'Lv 19 · Catacombs · 8h 41m' }, + { id: 's3', empty: true }, +] + +// ── renderRow hooks ────────────────────────────────────────────────────────── + +const renderMuted = (p: any) => + `
  • ` + + `
    ` + + `${esc(p.name)}` + + (p.reason ? `${esc(p.reason)}` : '') + + `
    ` + + (p.mutedAt ? `${esc(p.mutedAt)}` : '') + + `` + + `
  • ` + +const renderMember = (m: any) => { + const avatar = m.avatarUrl + ? `${esc(m.name)}` + : `` + return ( + `
  • ` + + avatar + + `
    ` + + `${esc(m.name)}` + + (m.email ? `${esc(m.email)}` : '') + + `
    ` + + (m.role ? `${esc(m.role)}` : '') + + `
  • ` + ) +} + +const renderCredit = (s: any) => + `
  • ` + + `
    ` + + `${esc(s.role)}` + + (s.names as string[]) + .map((n) => `${esc(n)}`) + .join('') + + `
    ` + + `
  • ` + +const renderAchievement = (a: any) => { + const hasProgress = !a.unlocked && typeof a.target === 'number' + const pct = hasProgress ? Math.min(100, Math.round((a.progress / a.target) * 100)) : 0 + const dot = `` + const progress = hasProgress + ? `
    ` + + `${a.progress}/${a.target}` + : '' + return ( + `
  • ` + + dot + + `
    ` + + `${esc(a.name)}` + + (a.description + ? `${esc(a.description)}` + : '') + + progress + + `
    ` + + (typeof a.points === 'number' + ? `${a.points} pts` + : '') + + `
  • ` + ) +} + +const renderFriend = (f: any) => { + const color = STATUS_COLOR[f.status] ?? STATUS_COLOR.offline + const activity = f.activity ?? (f.status === 'offline' ? 'Offline' : '') + return ( + `
  • ` + + `` + + `
    ` + + `${esc(f.name)}` + + (activity ? `${esc(activity)}` : '') + + `
    ` + + (f.rank ? `${esc(f.rank)}` : '') + + `` + + `` + + `
  • ` + ) +} + +// Selectable (listbox) row — note role="option" + tabindex so keyboard select works. +const renderSlot = (s: any) => { + const primary = s.empty + ? `Empty Slot` + : `${esc(s.name)}` + const meta = s.meta ? `${esc(s.meta)}` : '' + const actions = s.empty + ? '' + : `` + + (s.autosave + ? '' + : ``) + return ( + `
  • ` + + `
    ` + + primary + + meta + + `
    ` + + (s.autosave ? `Auto` : '') + + actions + + `
  • ` + ) +} + +const DataListDemo: React.FC = () => { + const muteRef = useRef(null) + const teamRef = useRef(null) + const creditsRef = useRef(null) + const achRef = useRef(null) + const friendsRef = useRef(null) + const slotsRef = useRef(null) + const emptyRef = useRef(null) + const [log, setLog] = useState([]) + const [slotLog, setSlotLog] = useState([]) + + useEffect(() => { + if (muteRef.current) { + muteRef.current.renderRow = renderMuted + muteRef.current.items = MUTED + const handler = (e: any) => + setLog((l) => + [`tc-action — action: "${e.detail.action}", id: "${e.detail.id}"`, ...l].slice( + 0, + 8, + ), + ) + muteRef.current.addEventListener('tc-action', handler) + return () => muteRef.current?.removeEventListener('tc-action', handler) + } + }, []) + + useEffect(() => { + if (teamRef.current) { + teamRef.current.renderRow = renderMember + teamRef.current.items = TEAM + } + }, []) + + useEffect(() => { + if (creditsRef.current) { + creditsRef.current.renderRow = renderCredit + creditsRef.current.items = CREDITS + } + }, []) + + useEffect(() => { + if (achRef.current) { + achRef.current.renderRow = renderAchievement + achRef.current.items = ACHIEVEMENTS + } + }, []) + + useEffect(() => { + const el = friendsRef.current + if (!el) return + const online = FRIENDS.filter((f) => f.status !== 'offline').length + el.setAttribute('list-title', `Friends · ${online}/${FRIENDS.length}`) + el.renderRow = renderFriend + el.items = FRIENDS + }, []) + + useEffect(() => { + const el = slotsRef.current + if (!el) return + el.renderRow = renderSlot + el.items = SLOTS + const onAction = (e: any) => + setSlotLog((l) => + [`tc-action — "${e.detail.action}" id="${e.detail.id}"`, ...l].slice(0, 8), + ) + const onSelect = (e: any) => + setSlotLog((l) => [`tc-select — id="${e.detail.id}"`, ...l].slice(0, 8)) + el.addEventListener('tc-action', onAction) + el.addEventListener('tc-select', onSelect) + return () => { + el.removeEventListener('tc-action', onAction) + el.removeEventListener('tc-select', onSelect) + } + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Click Unmute… + ) : ( +
      + {log.map((line, i) => ( +
    • + {line} +
    • + ))} +
    + )} +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    + Event log + {slotLog.length === 0 ? ( + + Select a slot or click an action… + + ) : ( +
      + {slotLog.map((line, i) => ( +
    • + {line} +
    • + ))} +
    + )} +
    +
    + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default DataListDemo diff --git a/examples/src/web-components/DatePickerDemo.tsx b/examples/src/web-components/DatePickerDemo.tsx new file mode 100644 index 00000000..4e4bd9f6 --- /dev/null +++ b/examples/src/web-components/DatePickerDemo.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useRef, useState } from 'react' + +function useDateValue(initial: string): [string, React.RefObject] { + const [value, setValue] = useState(initial) + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + const handler = (e: CustomEvent) => setValue(e.detail.value) + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + return [value, ref] +} + +const DatePickerDemo: React.FC = () => { + const [v1, ref1] = useDateValue('2026-06-14') + const [v2, ref2] = useDateValue('') + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + +
    Selected: {v1 || '—'}
    +
    +
    + + +
    + {/* @ts-ignore */} + +
    Selected: {v2 || '—'}
    +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default DatePickerDemo diff --git a/examples/src/web-components/DebugOverlayDemo.tsx b/examples/src/web-components/DebugOverlayDemo.tsx new file mode 100644 index 00000000..ff3c2437 --- /dev/null +++ b/examples/src/web-components/DebugOverlayDemo.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useRef } from 'react' + +const DebugOverlayDemo: React.FC = () => { + const customRef = useRef(null) + const liveRef = useRef(null) + + useEffect(() => { + if (customRef.current) { + customRef.current.rows = [ + { label: 'Entities', value: 1_284 }, + { label: 'Sprites', value: 642 }, + { label: 'Audio', value: '12 / 32' }, + { label: 'Scene', value: 'Level 3' }, + ] + } + }, []) + + // Drive the live panel with a fluctuating fps reading so the status colour + // transitions (good → warning → danger) are visible. + useEffect(() => { + const el = liveRef.current + if (!el) return + let frame = 0 + const id = window.setInterval(() => { + frame += 1 + const fps = 30 + Math.round(30 * Math.abs(Math.sin(frame / 6))) + el.fps = fps + el.drawCalls = 120 + (frame % 40) + el.memMb = 256 + (frame % 16) + }, 600) + return () => window.clearInterval(id) + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default DebugOverlayDemo diff --git a/examples/src/web-components/DialogueBoxDemo.tsx b/examples/src/web-components/DialogueBoxDemo.tsx new file mode 100644 index 00000000..127d2408 --- /dev/null +++ b/examples/src/web-components/DialogueBoxDemo.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef, useState } from 'react' + +const DialogueBoxDemo: React.FC = () => { + const choiceRef = useRef(null) + const advanceRef = useRef(null) + const [lastChoice, setLastChoice] = useState('—') + const [advanceCount, setAdvanceCount] = useState(0) + + // choices is a JS property (array) and tc-choice is a CustomEvent — both need a ref. + useEffect(() => { + const el = choiceRef.current + if (!el) return + el.choices = [ + { id: 'accept', label: 'Accept the quest' }, + { id: 'decline', label: 'Decline politely' }, + { id: 'locked', label: 'Ask for more gold', disabled: true }, + ] + const onChoice = (e: any) => setLastChoice(e.detail.id) + el.addEventListener('tc-choice', onChoice) + return () => el.removeEventListener('tc-choice', onChoice) + }, []) + + useEffect(() => { + const el = advanceRef.current + if (!el) return + const onAdvance = () => setAdvanceCount((c) => c + 1) + el.addEventListener('tc-advance', onAdvance) + return () => el.removeEventListener('tc-advance', onAdvance) + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + +

    + Advanced {advanceCount} time{advanceCount === 1 ? '' : 's'}{' '} + (click the box after it finishes typing). +

    +
    + + + {/* @ts-ignore */} + +

    + Last choice: {lastChoice} +

    +
    + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default DialogueBoxDemo diff --git a/examples/src/web-components/DiffViewerDemo.tsx b/examples/src/web-components/DiffViewerDemo.tsx new file mode 100644 index 00000000..e6f1b78c --- /dev/null +++ b/examples/src/web-components/DiffViewerDemo.tsx @@ -0,0 +1,128 @@ +import React, { useRef, useEffect } from 'react' + +const BEFORE_TS = [ + 'interface Config {', + ' host: string', + ' port: number', + '}', + '', + 'function createServer(config: Config) {', + ' const { host, port } = config', + ' console.log(`Starting on ${host}:${port}`)', + ' return { host, port }', + '}', +].join('\n') + +const AFTER_TS = [ + 'interface Config {', + ' host: string', + ' port: number', + ' debug?: boolean', + '}', + '', + 'function createServer(config: Config): void {', + ' const { host, port, debug = false } = config', + ' if (debug) console.log(`Starting on ${host}:${port}`)', + '}', +].join('\n') + +const BEFORE_BASH = [ + '#!/usr/bin/env bash', + 'set -e', + '', + 'echo "Deploying app"', + 'npm install', + 'npm run build', +].join('\n') + +const AFTER_BASH = [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + '', + 'APP_NAME="my-app"', + '', + 'echo "Deploying $APP_NAME"', + 'npm ci', + 'npm run build', + 'npm run test', +].join('\n') + +const DiffViewerDemo: React.FC = () => { + const splitRef = useRef(null) + const unifiedRef = useRef(null) + + useEffect(() => { + const splitEl = splitRef.current + const unifiedEl = unifiedRef.current + if (splitEl) { + splitEl.before = BEFORE_TS + splitEl.after = AFTER_TS + } + if (unifiedEl) { + unifiedEl.before = BEFORE_BASH + unifiedEl.after = AFTER_BASH + } + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default DiffViewerDemo diff --git a/examples/src/web-components/DifferenceCardDemo.tsx b/examples/src/web-components/DifferenceCardDemo.tsx new file mode 100644 index 00000000..14d891c7 --- /dev/null +++ b/examples/src/web-components/DifferenceCardDemo.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useRef } from 'react' + +const DifferenceCardDemo: React.FC = () => { + const formattedRef = useRef(null) + + useEffect(() => { + if (formattedRef.current) { + formattedRef.current.formatValue = (v: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(v) + } + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default DifferenceCardDemo diff --git a/examples/src/web-components/DividerDemo.tsx b/examples/src/web-components/DividerDemo.tsx new file mode 100644 index 00000000..ca8bff5b --- /dev/null +++ b/examples/src/web-components/DividerDemo.tsx @@ -0,0 +1,81 @@ +import React from 'react' + +const DividerDemo: React.FC = () => ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + or + + + +
    + Left + {/* @ts-ignore */} + + Right +
    +
    + + +
    + Edit + {/* @ts-ignore */} + + Duplicate + {/* @ts-ignore */} + + Delete +
    +
    + + +
    +
    Panel A
    + {/* @ts-ignore */} + +
    Panel B
    + {/* @ts-ignore */} + +
    Panel C
    +
    +
    +
    +
    +
    +
    +
    +) + +export default DividerDemo diff --git a/examples/src/web-components/DownloadStatsDemo.tsx b/examples/src/web-components/DownloadStatsDemo.tsx new file mode 100644 index 00000000..a9150c9f --- /dev/null +++ b/examples/src/web-components/DownloadStatsDemo.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useRef } from 'react' + +const SPARKLINE_DATA = [12, 18, 15, 24, 20, 32, 28, 40, 35, 48, 42, 55] + +const DownloadStatsDemo: React.FC = () => { + const npmRef = useRef(null) + const pypiRef = useRef(null) + const cratesRef = useRef(null) + + useEffect(() => { + if (npmRef.current) { + npmRef.current.sparkline = SPARKLINE_DATA + } + }, []) + + useEffect(() => { + if (pypiRef.current) { + pypiRef.current.sparkline = [8, 10, 9, 14, 12, 18, 16, 22, 20, 28] + } + }, []) + + useEffect(() => { + if (cratesRef.current) { + cratesRef.current.sparkline = [3, 5, 4, 7, 6, 9, 8, 12, 11, 15] + } + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default DownloadStatsDemo diff --git a/examples/src/web-components/DrawerDemo.tsx b/examples/src/web-components/DrawerDemo.tsx new file mode 100644 index 00000000..c616da86 --- /dev/null +++ b/examples/src/web-components/DrawerDemo.tsx @@ -0,0 +1,120 @@ +import React, { useRef, useState, useEffect } from 'react' + +type DrawerVariant = { + side: string + size?: string + pinned?: boolean + title: string + description: string +} + +const VARIANTS: DrawerVariant[] = [ + { + side: 'right', + title: 'Right Drawer', + description: + 'Default — slides in from the right edge. Press Escape or click the backdrop to close.', + }, + { side: 'left', title: 'Left Drawer', description: 'Slides in from the left edge.' }, + { side: 'top', title: 'Top Drawer', description: 'Slides down from the top edge.' }, + { side: 'bottom', title: 'Bottom Drawer', description: 'Slides up from the bottom edge.' }, + { + side: 'right', + size: 'small', + title: 'Small Drawer', + description: 'Uses size="small" for a narrower panel.', + }, + { + side: 'right', + size: 'large', + title: 'Large Drawer', + description: 'Uses size="large" for a wide panel.', + }, + { + side: 'right', + pinned: true, + title: 'Pinned Drawer', + description: + 'No backdrop, no body scroll lock — the page stays interactive behind the panel. Close with the button or Escape.', + }, +] + +const DrawerDemo: React.FC = () => { + const [openIdx, setOpenIdx] = useState(null) + const drawerRefs = useRef<(HTMLElement | null)[]>(VARIANTS.map(() => null)) + + useEffect(() => { + const listeners: Array<() => void> = [] + VARIANTS.forEach((_, idx) => { + const el = drawerRefs.current[idx] + if (!el) return + const handler = () => setOpenIdx(null) + el.addEventListener('tc-close', handler) + listeners.push(() => el.removeEventListener('tc-close', handler)) + }) + return () => listeners.forEach((remove) => remove()) + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + {VARIANTS.map((v, idx) => { + const sectionTitle = v.pinned + ? 'pinned — no backdrop, no scroll lock' + : v.size + ? `size="${v.size}" — ${v.size} panel` + : `side="${v.side}" — slide from ${v.side}` + + const attrs: Record = { + title: v.title, + side: v.side, + } + if (v.size) attrs.size = v.size + if (v.pinned) attrs.pinned = true + + return ( + + + {/* @ts-ignore */} + { + drawerRefs.current[idx] = el + }} + title={v.title} + side={v.side} + size={v.size} + open={openIdx === idx || undefined} + pinned={v.pinned || undefined} + > +

    {v.description}

    + {/* @ts-ignore */} +
    +
    + ) + })} +
    +
    +
    +
    +
    + ) +} + +export default DrawerDemo diff --git a/examples/src/web-components/DropdownDemo.tsx b/examples/src/web-components/DropdownDemo.tsx new file mode 100644 index 00000000..d96d19a3 --- /dev/null +++ b/examples/src/web-components/DropdownDemo.tsx @@ -0,0 +1,173 @@ +import React, { useRef } from 'react' + +const DropdownDemo: React.FC = () => { + const dropdownRef = useRef(null) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + + View profile + Edit settings + + Sign out + + + + +
    + {[ + 'primary', + 'secondary', + 'success', + 'danger', + 'warning', + 'info', + 'light', + 'dark', + ].map((v) => ( + + Option one + Option two + + ))} +
    +
    + + +
    + + Action + Another action + + + Action + Another action + +
    +
    + + +
    + + Item + + + Item + + + Item + + + Item + +
    +
    + + +
    + + Closes on any click + + + + Closes only on inside click + + + + + Closes only on outside click + + + + Never auto-closes + +
    +
    + + + + Current page + Settings + + Unavailable + + + + +
    + + + +
    + + Alpha + Beta + Gamma + +
    +
    +
    +
    +
    +
    + ) +} + +export default DropdownDemo diff --git a/examples/src/web-components/EarlySignupFormDemo.tsx b/examples/src/web-components/EarlySignupFormDemo.tsx new file mode 100644 index 00000000..8ca90f18 --- /dev/null +++ b/examples/src/web-components/EarlySignupFormDemo.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useRef, useState } from 'react' + +const BENEFITS = [ + 'Zero-config setup — drop in a script tag and go', + 'Framework-free: React, Vue, Svelte, or plain HTML', + 'Lightweight — no runtime dependencies', + 'Full accessibility baked in (WCAG 2.1 AA)', +] + +const EarlySignupFormDemo: React.FC = () => { + const lightRef = useRef(null) + const darkRef = useRef(null) + const loadingRef = useRef(null) + const successRef = useRef(null) + const [lightLog, setLightLog] = useState('') + const [darkLog, setDarkLog] = useState('') + + // Light variant — benefits via JS property, listen to tc-submit + useEffect(() => { + const el = lightRef.current + if (!el) return + el.benefits = BENEFITS + const handler = (e: CustomEvent) => + setLightLog(`tc-submit fired: ${JSON.stringify(e.detail)}`) + el.addEventListener('tc-submit', handler) + return () => el.removeEventListener('tc-submit', handler) + }, []) + + // Dark variant — benefits via JS property + useEffect(() => { + const el = darkRef.current + if (!el) return + el.benefits = [ + 'Priority access to beta features', + 'Invite 3 teammates for free', + 'Lifetime 20% discount', + ] + const handler = (e: CustomEvent) => + setDarkLog(`tc-submit fired: ${JSON.stringify(e.detail)}`) + el.addEventListener('tc-submit', handler) + return () => el.removeEventListener('tc-submit', handler) + }, []) + + // Loading state variant + useEffect(() => { + const el = loadingRef.current + if (!el) return + el.benefits = ['Always up to date', 'SLA-backed reliability'] + }, []) + + // Pre-rendered success state — drive the email field + submit the form + // programmatically so the confirmation state renders on mount. + useEffect(() => { + const el = successRef.current + if (!el) return + el.benefits = ['Instant access confirmation', 'Personal onboarding call'] + // Defer a tick so the component has finished its initial render. + const id = window.setTimeout(() => { + const input = el.querySelector('input[name="email"]') as HTMLInputElement | null + const form = el.querySelector('.tc-early-signup-form__form') as HTMLFormElement | null + if (input && form) { + input.value = 'dev@example.com' + form.requestSubmit() + } + }, 0) + return () => window.clearTimeout(id) + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + + {/* @ts-ignore */} + {lightLog && ( +

    + {lightLog} +

    + )} +
    + + + {/* @ts-ignore */} + + {darkLog && ( +

    + {darkLog} +

    + )} +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default EarlySignupFormDemo diff --git a/examples/src/web-components/EcosystemMapDemo.tsx b/examples/src/web-components/EcosystemMapDemo.tsx new file mode 100644 index 00000000..1928fb11 --- /dev/null +++ b/examples/src/web-components/EcosystemMapDemo.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useRef } from 'react' + +const EcosystemMapDemo: React.FC = () => { + const twoRingsRef = useRef(null) + const threeRingsRef = useRef(null) + + useEffect(() => { + if (twoRingsRef.current) { + twoRingsRef.current.core = { name: '@your/core', label: 'core' } + twoRingsRef.current.rings = [ + { + label: 'Official', + items: [ + { name: '@your/auth' }, + { name: '@your/cache' }, + { name: '@your/queue' }, + { name: '@your/router' }, + ], + }, + { + label: 'Community', + items: [ + { name: 'lib-x' }, + { name: 'lib-y' }, + { name: 'lib-z' }, + { name: 'lib-foo' }, + { name: 'lib-bar' }, + { name: 'lib-baz' }, + ], + }, + ] + twoRingsRef.current.addEventListener('tc-select', (e: CustomEvent) => { + console.log('[tc-ecosystem-map] tc-select', e.detail) + }) + } + }, []) + + useEffect(() => { + if (threeRingsRef.current) { + threeRingsRef.current.core = { name: 'toolcase', label: 'runtime' } + threeRingsRef.current.rings = [ + { + label: 'Base', + items: [ + { name: '@toolcase/base' }, + { name: '@toolcase/logging' }, + { name: '@toolcase/serializer' }, + ], + }, + { + label: 'UI', + items: [ + { name: '@toolcase/web-components' }, + ], + }, + { + label: 'Integration', + items: [{ name: '@toolcase/phaser-plus' }, { name: '@toolcase/node' }], + }, + ] + } + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default EcosystemMapDemo diff --git a/examples/src/web-components/EditableTextDemo.tsx b/examples/src/web-components/EditableTextDemo.tsx new file mode 100644 index 00000000..d7c14be5 --- /dev/null +++ b/examples/src/web-components/EditableTextDemo.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useRef, useState } from 'react' + +const EditableTextDemo: React.FC = () => { + const [committed, setCommitted] = useState('Project Alpha') + const listenerRef = useRef(null) + + useEffect(() => { + const el = listenerRef.current + if (!el) return + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail + setCommitted(detail.value) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + Name: + +
    +
    + Last committed: {committed} +
    +
    + + +
    + Label: + {/* @ts-ignore */} + +
    +
    + + +
    + Status: + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default EditableTextDemo diff --git a/examples/src/web-components/EmptyStateDemo.tsx b/examples/src/web-components/EmptyStateDemo.tsx new file mode 100644 index 00000000..58361054 --- /dev/null +++ b/examples/src/web-components/EmptyStateDemo.tsx @@ -0,0 +1,50 @@ +import React from 'react' + +const EmptyStateDemo: React.FC = () => ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + + No messages yet + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + No files found + {/* @ts-ignore */} + Upload a file + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + Nothing to show here yet. + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    +) + +export default EmptyStateDemo diff --git a/examples/src/web-components/EntityCellDemo.tsx b/examples/src/web-components/EntityCellDemo.tsx new file mode 100644 index 00000000..eb1a2307 --- /dev/null +++ b/examples/src/web-components/EntityCellDemo.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useRef, useState } from 'react' + +const EntityCellDemo: React.FC = () => { + const [lastClicked, setLastClicked] = useState(null) + const clickableRef = useRef(null) + + useEffect(() => { + const el = clickableRef.current + if (!el) return + const handler = (e: CustomEvent) => { + setLastClicked(e.detail.name ?? '(unknown)') + } + el.addEventListener('tc-click', handler) + return () => el.removeEventListener('tc-click', handler) + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {lastClicked !== null && ( +

    + tc-click fired — name:{' '} + {lastClicked} +

    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default EntityCellDemo diff --git a/examples/src/web-components/EntityProfileCardDemo.tsx b/examples/src/web-components/EntityProfileCardDemo.tsx new file mode 100644 index 00000000..d359f23c --- /dev/null +++ b/examples/src/web-components/EntityProfileCardDemo.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useRef } from 'react' + +const BASIC_META = [ + { label: 'Location', value: 'San Francisco, CA' }, + { label: 'Language', value: 'TypeScript' }, + { label: 'Stars', value: '12.4k' }, + { label: 'Joined', value: 'Jan 2019' }, +] + +const FULL_META = [ + { label: 'Role', value: 'Senior Engineer' }, + { label: 'Team', value: 'Frontend' }, + { label: 'Location', value: 'San Francisco, CA' }, + { label: 'Since', value: 'Mar 2021' }, + { label: 'PRs', value: '342' }, + { label: 'Reviews', value: '1,204' }, +] + +const EntityProfileCardDemo: React.FC = () => { + const basicRef = useRef(null) + const fullRef = useRef(null) + + useEffect(() => { + if (basicRef.current) { + basicRef.current.meta = BASIC_META + } + }, []) + + useEffect(() => { + if (fullRef.current) { + fullRef.current.meta = FULL_META + } + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Alice Chen + + Senior Frontend Engineer · Anthropic + + {/* @ts-ignore */} + + TypeScript + + {/* @ts-ignore */} + + React + + {/* @ts-ignore */} + + Web Components + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default EntityProfileCardDemo diff --git a/examples/src/web-components/EquipmentDollDemo.tsx b/examples/src/web-components/EquipmentDollDemo.tsx new file mode 100644 index 00000000..7bf8fc1c --- /dev/null +++ b/examples/src/web-components/EquipmentDollDemo.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useRef, useState } from 'react' + +const HUMANOID_SLOTS = [ + { id: 'head', label: 'Head', x: 50, y: 8, item: { id: 'helm', name: 'Iron Helm', icon: 'H' } }, + { id: 'cape', label: 'Cape', x: 82, y: 18, item: null }, + { + id: 'amulet', + label: 'Amulet', + x: 18, + y: 18, + item: { id: 'amulet', name: 'Amulet', icon: 'A' }, + }, + { + id: 'chest', + label: 'Chest', + x: 50, + y: 34, + item: { id: 'plate', name: 'Plate Armor', icon: 'C' }, + }, + { + id: 'main-hand', + label: 'Main', + x: 14, + y: 46, + item: { id: 'sword', name: 'Longsword', icon: 'S' }, + }, + { + id: 'off-hand', + label: 'Off', + x: 86, + y: 46, + item: { id: 'shield', name: 'Shield', icon: 'D' }, + }, + { + id: 'gloves', + label: 'Gloves', + x: 18, + y: 64, + item: { id: 'gloves', name: 'Gauntlets', icon: 'G' }, + }, + { id: 'belt', label: 'Belt', x: 50, y: 60, item: null }, + { + id: 'ring', + label: 'Ring', + x: 82, + y: 64, + item: { id: 'ring', name: 'Signet Ring', icon: 'R' }, + }, + { + id: 'legs', + label: 'Legs', + x: 50, + y: 78, + item: { id: 'greaves', name: 'Greaves', icon: 'L' }, + }, + { + id: 'feet', + label: 'Feet', + x: 50, + y: 94, + item: { id: 'boots', name: 'Boots', icon: 'B', qty: 2 }, + }, +] + +const COMPACT_SLOTS = [ + { + id: 'weapon', + label: 'Weapon', + x: 22, + y: 28, + item: { id: 'blaster', name: 'Blaster', icon: 'W' }, + }, + { id: 'armor', label: 'Armor', x: 50, y: 50, item: { id: 'vest', name: 'Vest', icon: 'V' } }, + { id: 'mod', label: 'Mod', x: 78, y: 28, item: null }, + { + id: 'boost', + label: 'Boost', + x: 50, + y: 82, + item: { id: 'boost', name: 'Jetpack', icon: 'J' }, + }, +] + +const EquipmentDollDemo: React.FC = () => { + const mainRef = useRef(null) + const compactRef = useRef(null) + const customRef = useRef(null) + const [selected, setSelected] = useState('chest') + + useEffect(() => { + const el = mainRef.current + if (!el) return + el.slots = HUMANOID_SLOTS + const onSelect = (e: CustomEvent<{ id: string }>) => setSelected(e.detail.id) + el.addEventListener('tc-select', onSelect as EventListener) + return () => el.removeEventListener('tc-select', onSelect as EventListener) + }, []) + + useEffect(() => { + if (compactRef.current) compactRef.current.slots = COMPACT_SLOTS + }, []) + + useEffect(() => { + if (customRef.current) customRef.current.slots = HUMANOID_SLOTS + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + +

    + Selected slot: {selected || '—'} +

    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default EquipmentDollDemo diff --git a/examples/src/web-components/ExtendedSelectDemo.tsx b/examples/src/web-components/ExtendedSelectDemo.tsx new file mode 100644 index 00000000..2f4b6ebd --- /dev/null +++ b/examples/src/web-components/ExtendedSelectDemo.tsx @@ -0,0 +1,190 @@ +import React, { useEffect, useRef, useState } from 'react' + +const FRAMEWORK_ITEMS = [ + { + key: 'react', + label: 'React', + description: 'A JavaScript library for building user interfaces', + }, + { key: 'vue', label: 'Vue', description: 'The progressive JavaScript framework' }, + { key: 'svelte', label: 'Svelte', description: 'Cybernetically enhanced web apps' }, + { + key: 'angular', + label: 'Angular', + description: 'Platform for building mobile & desktop apps', + }, + { + key: 'solid', + label: 'SolidJS', + description: 'Simple and performant reactivity for building user interfaces', + }, + { key: 'qwik', label: 'Qwik', description: 'Instant-on web apps at any scale' }, +] + +const COUNTRY_ITEMS = [ + { key: 'us', label: 'United States' }, + { key: 'gb', label: 'United Kingdom' }, + { key: 'de', label: 'Germany' }, + { key: 'fr', label: 'France' }, + { key: 'jp', label: 'Japan' }, + { key: 'ca', label: 'Canada' }, + { key: 'au', label: 'Australia' }, + { key: 'br', label: 'Brazil' }, +] + +const ExtendedSelectDemo: React.FC = () => { + const basicRef = useRef(null) + const preselectedRef = useRef(null) + const loadingRef = useRef(null) + const formRef = useRef(null) + const [selected, setSelected] = useState(null) + const [preselectedValue, setPreselectedValue] = useState('react') + const [submitted, setSubmitted] = useState(null) + + useEffect(() => { + if (!basicRef.current) return + basicRef.current.items = FRAMEWORK_ITEMS + const handler = (e: Event) => { + setSelected((e as CustomEvent<{ value: string }>).detail.value) + } + basicRef.current.addEventListener('tc-change', handler) + return () => basicRef.current?.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + if (!preselectedRef.current) return + preselectedRef.current.items = COUNTRY_ITEMS + const handler = (e: Event) => { + setPreselectedValue((e as CustomEvent<{ value: string }>).detail.value) + } + preselectedRef.current.addEventListener('tc-change', handler) + return () => preselectedRef.current?.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + if (!loadingRef.current) return + loadingRef.current.items = FRAMEWORK_ITEMS + }, []) + + useEffect(() => { + if (!formRef.current) return + formRef.current.items = FRAMEWORK_ITEMS + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +

    + Items are set via the items JS property. Each item + can carry an optional description line. Type to + filter (debounced 150 ms); Arrow keys navigate; Enter selects; + Escape closes. +

    +
    + {/* @ts-ignore */} + +
    + {selected && ( +

    + Selected: {selected} +

    + )} +
    + + +

    + Pass a value attribute (or JS property) to set an + initial selection. The trigger shows the item's label; a check + mark highlights the selected row in the list. +

    +
    + {/* @ts-ignore */} + +
    +

    + Current value: {preselectedValue} +

    +
    + + +

    + Set the boolean loading attribute to disable the + trigger and show a spinner inside the menu. The dropdown cannot + be opened while loading. +

    +
    + {/* @ts-ignore */} + +
    +
    + + +

    + Inside a <form> the selected value is + submitted via a hidden{' '} + <input type="hidden">. The{' '} + name attribute sets the field name for{' '} + FormData. +

    +
    { + e.preventDefault() + const fd = new FormData(e.currentTarget) + setSubmitted(String(fd.get('tool') ?? '')) + }} + style={{ maxWidth: 400 }} + > + {/* @ts-ignore */} + + + + {submitted !== null && ( +

    + Submitted tool:{' '} + {submitted || '(empty)'} +

    + )} +
    +
    +
    +
    +
    +
    + ) +} + +export default ExtendedSelectDemo diff --git a/examples/src/web-components/EyebrowDemo.tsx b/examples/src/web-components/EyebrowDemo.tsx new file mode 100644 index 00000000..75af521f --- /dev/null +++ b/examples/src/web-components/EyebrowDemo.tsx @@ -0,0 +1,58 @@ +import React from 'react' + +const EyebrowDemo: React.FC = () => ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + What's new +

    Release 2.0 is here

    +
    +
    + + +
    + {/* @ts-ignore */} + Getting started + {/* @ts-ignore */} + Featured + {/* @ts-ignore */} + Step 01 +
    +
    + + +
    + {/* @ts-ignore */} + Accented eyebrow +

    Override the contract, not the markup

    +
    +
    +
    +
    +
    +
    +
    +) + +export default EyebrowDemo diff --git a/examples/src/web-components/FAQListDemo.tsx b/examples/src/web-components/FAQListDemo.tsx new file mode 100644 index 00000000..1506d715 --- /dev/null +++ b/examples/src/web-components/FAQListDemo.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useRef, useState } from 'react' + +const FAQ_ITEMS = [ + { + question: 'What is @toolcase/web-components?', + answer: 'A library of framework-free custom elements (tc-*) for building accessible UI without React, Vue, or Angular.', + }, + { + question: 'Do I need a build tool to use it?', + answer: 'No. Import the ESM bundle from a CDN and drop tc-* tags into any HTML page.', + }, + { + question: 'Can I use it alongside React?', + answer: 'Yes — author tc-* tags in JSX, set JS properties via refs, and listen for tc-* events.', + }, + { + question: 'How do I theme the components?', + answer: 'Override --tc-* and --bs--* CSS custom properties on the host or a parent element.', + }, +] + +const FAQListDemo: React.FC = () => { + const basicRef = useRef(null) + const schemaRef = useRef(null) + const evtRef = useRef(null) + const slotRef = useRef(null) + const [lastToggle, setLastToggle] = useState('') + + useEffect(() => { + if (basicRef.current) basicRef.current.items = FAQ_ITEMS + }, []) + + useEffect(() => { + if (schemaRef.current) schemaRef.current.items = FAQ_ITEMS + }, []) + + useEffect(() => { + const el = evtRef.current + if (!el) return + el.items = FAQ_ITEMS + el.defaultOpen = [0, 2] + const handler = (e: CustomEvent) => { + setLastToggle(`Item ${e.detail.index} ${e.detail.open ? 'opened' : 'closed'}`) + } + el.addEventListener('tc-toggle', handler) + return () => el.removeEventListener('tc-toggle', handler) + }, []) + + useEffect(() => { + if (slotRef.current) slotRef.current.items = FAQ_ITEMS + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + +

    + Items 0 and 2 are open by default. Last event:{' '} + {lastToggle || '—'} +

    + {/* @ts-ignore */} + +
    + + + {/* @ts-ignore */} + + + Custom slotted title + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default FAQListDemo diff --git a/examples/src/web-components/FPSCapSelectDemo.tsx b/examples/src/web-components/FPSCapSelectDemo.tsx new file mode 100644 index 00000000..6928a531 --- /dev/null +++ b/examples/src/web-components/FPSCapSelectDemo.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useRef, useState } from 'react' + +function useFPSCapValue(initial: string): [string, React.RefObject] { + const [value, setValue] = useState(initial) + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ value: string }>).detail + if (detail) setValue(detail.value) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + return [value, ref] +} + +const FPSCapSelectDemo: React.FC = () => { + const [v1, ref1] = useFPSCapValue('60') + const [v2, ref2] = useFPSCapValue('30') + + // Custom option set, supplied via the `options` JS property (not an attribute). + const ref3 = useRef(null) + useEffect(() => { + const el = ref3.current + if (!el) return + el.options = [ + { value: '60', label: '60 FPS' }, + { value: '90', label: '90 FPS' }, + { value: '165', label: '165 FPS' }, + { value: '0', label: 'Uncapped' }, + ] + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + +
    +
    Current value: {v1}
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    Current value: {v2}
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default FPSCapSelectDemo diff --git a/examples/src/web-components/FeatureCardDemo.tsx b/examples/src/web-components/FeatureCardDemo.tsx new file mode 100644 index 00000000..6ead7d17 --- /dev/null +++ b/examples/src/web-components/FeatureCardDemo.tsx @@ -0,0 +1,186 @@ +import React from 'react' + +const FeatureCardDemo: React.FC = () => { + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + + {/* @ts-ignore */} + + + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + + + +
    + VISUAL SLOT +
    +
    +
    +
    + + +
    + {/* @ts-ignore */} + + Micro-services made simple + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default FeatureCardDemo diff --git a/examples/src/web-components/FeatureMatrixDemo.tsx b/examples/src/web-components/FeatureMatrixDemo.tsx new file mode 100644 index 00000000..a35d8ccf --- /dev/null +++ b/examples/src/web-components/FeatureMatrixDemo.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useRef } from 'react' + +const FeatureMatrixDemo: React.FC = () => { + const fullRef = useRef(null) + const partialRef = useRef(null) + const slottedRef = useRef(null) + + useEffect(() => { + if (!fullRef.current) return + fullRef.current.columns = [ + { id: 'free', label: 'Free' }, + { id: 'pro', label: 'Pro', highlight: true }, + { id: 'enterprise', label: 'Enterprise', highlight: true }, + ] + fullRef.current.rows = [ + { + label: 'Custom domains', + hint: 'Bring your own domain', + values: { free: false, pro: true, enterprise: true }, + }, + { + label: 'Analytics', + hint: 'Traffic & engagement metrics', + values: { free: 'partial', pro: true, enterprise: true }, + }, + { + label: 'API access', + values: { free: false, pro: true, enterprise: true }, + }, + { + label: 'SSO / SAML', + hint: 'Single sign-on support', + values: { free: false, pro: false, enterprise: true }, + }, + { + label: 'SLA uptime', + values: { free: '99.5%', pro: '99.9%', enterprise: '99.99%' }, + }, + { + label: 'Priority support', + values: { free: false, pro: 'partial', enterprise: true }, + }, + ] + }, []) + + useEffect(() => { + if (!partialRef.current) return + partialRef.current.columns = [ + { id: 'basic', label: 'Basic' }, + { id: 'standard', label: 'Standard', highlight: true }, + { id: 'advanced', label: 'Advanced' }, + ] + partialRef.current.rows = [ + { + label: 'Storage', + values: { basic: '5 GB', standard: '50 GB', advanced: 'Unlimited' }, + }, + { label: 'Collaboration', values: { basic: false, standard: true, advanced: true } }, + { label: 'Audit log', values: { basic: false, standard: 'partial', advanced: true } }, + ] + }, []) + + useEffect(() => { + if (!slottedRef.current) return + slottedRef.current.columns = [ + { id: 'oss', label: 'Open Source' }, + { id: 'cloud', label: 'Cloud', highlight: true }, + ] + slottedRef.current.rows = [ + { label: 'Self-hosted', values: { oss: true, cloud: false } }, + { label: 'Managed updates', values: { oss: false, cloud: true } }, + { label: 'Plugin support', values: { oss: true, cloud: 'partial' } }, + ] + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + Open Source vs Cloud + + + +
    +
    +
    +
    +
    + ) +} + +export default FeatureMatrixDemo diff --git a/examples/src/web-components/FileDemo.tsx b/examples/src/web-components/FileDemo.tsx new file mode 100644 index 00000000..36573054 --- /dev/null +++ b/examples/src/web-components/FileDemo.tsx @@ -0,0 +1,213 @@ +import React, { useEffect, useRef, useState } from 'react' + +const TAGS = [ + { id: 'design', label: 'Design', color: 'var(--tc-info, #0ea5e9)' }, + { id: 'review', label: 'Review', color: 'var(--tc-warning, #f59e0b)' }, + { id: 'approved', label: 'Approved', color: 'var(--tc-success, #10b981)' }, + { id: 'draft', label: 'Draft' }, +] + +const MENU_ITEMS = [ + { key: 'rename', label: 'Rename', icon: 'Pencil' }, + { key: 'duplicate', label: 'Duplicate', icon: 'Copy' }, + { key: 'download', label: 'Download', icon: 'Download' }, + { key: 'delete', label: 'Delete', icon: 'Trash2' }, +] + +function EditableExample() { + const ref = useRef(null) + const [log, setLog] = useState([]) + + useEffect(() => { + const el = ref.current + if (!el) return + + el.tags = TAGS + el.tagIds = ['design', 'review'] + el.menuItems = MENU_ITEMS + + const onName = (e: CustomEvent) => { + setLog((prev) => [`name changed → "${e.detail.name}"`, ...prev].slice(0, 5)) + } + const onMenu = (e: CustomEvent) => { + setLog((prev) => [`menu clicked → "${e.detail.key}"`, ...prev].slice(0, 5)) + } + + el.addEventListener('tc-name-change', onName) + el.addEventListener('tc-menu-item-click', onMenu) + return () => { + el.removeEventListener('tc-name-change', onName) + el.removeEventListener('tc-menu-item-click', onMenu) + } + }, []) + + return ( +
    + {/* @ts-ignore */} + + {log.length > 0 && ( +
      + {log.map((entry, i) => ( +
    • + {entry} +
    • + ))} +
    + )} +
    + ) +} + +function ReadonlyExample() { + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + el.tags = TAGS + el.tagIds = ['approved'] + }, []) + + return ( + <> + {/* @ts-ignore */} + + + ) +} + +function LoadingExample() { + const [loaded, setLoaded] = useState(false) + + return ( +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + {loaded && ( + /* @ts-ignore */ + + )} +
    + ) +} + +function CallbackPropertyExample() { + const ref = useRef(null) + const [lastAction, setLastAction] = useState(null) + + useEffect(() => { + const el = ref.current + if (!el) return + el.menuItems = [ + { key: 'open', label: 'Open', icon: 'FolderOpen' }, + { key: 'share', label: 'Share', icon: 'Share2' }, + ] + el.onMenuItemClick = (key: string) => setLastAction(key) + el.onNameChange = (name: string) => setLastAction(`rename → ${name}`) + }, []) + + return ( +
    + {/* @ts-ignore */} + + {lastAction && ( +

    + Last action: {lastAction} +

    + )} +
    + ) +} + +const FileDemo: React.FC = () => ( +
    +
    +
    +
    + + + Web Components + + + +
    + + + + + + + + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + + + + + + + +
    +
    +
    +
    +
    +) + +export default FileDemo diff --git a/examples/src/web-components/FileDropzoneDemo.tsx b/examples/src/web-components/FileDropzoneDemo.tsx new file mode 100644 index 00000000..d1db559e --- /dev/null +++ b/examples/src/web-components/FileDropzoneDemo.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useRef, useState } from 'react' + +const SUPPORTED_FORMATS = [ + { label: 'PNG', mime: 'image/png', extension: '.png' }, + { label: 'JPG', mime: 'image/jpeg', extension: '.jpg' }, + { label: 'PDF', mime: 'application/pdf', extension: '.pdf' }, + { label: 'CSV', mime: 'text/csv', extension: '.csv' }, +] + +const FileDropzoneDemo: React.FC = () => { + const withFormatsRef = useRef(null) + const plainRef = useRef(null) + + const [droppedFiles, setDroppedFiles] = useState([]) + + useEffect(() => { + const el = withFormatsRef.current + if (!el) return + el.supported = SUPPORTED_FORMATS + + const handler = (e: Event) => { + const { files } = (e as CustomEvent<{ files: File[] }>).detail + const names = files.map((f: File) => `${f.name} (${(f.size / 1024).toFixed(1)} KB)`) + setDroppedFiles(names) + console.log('tc-files:', files) + } + el.addEventListener('tc-files', handler) + return () => el.removeEventListener('tc-files', handler) + }, []) + + useEffect(() => { + const el = plainRef.current + if (!el) return + const handler = (e: Event) => { + const { files } = (e as CustomEvent<{ files: File[] }>).detail + console.log('tc-files (plain):', files) + } + el.addEventListener('tc-files', handler) + return () => el.removeEventListener('tc-files', handler) + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + + {droppedFiles.length > 0 && ( +
    + Files received: {droppedFiles.join(', ')} +
    + )} +
    + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default FileDropzoneDemo diff --git a/examples/src/web-components/FileTagsDemo.tsx b/examples/src/web-components/FileTagsDemo.tsx new file mode 100644 index 00000000..3cbf7e36 --- /dev/null +++ b/examples/src/web-components/FileTagsDemo.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef, useState } from 'react' + +const ALL_TAGS = [ + { id: 'bug', label: 'bug', color: '#ef4444' }, + { id: 'feature', label: 'feature', color: '#3b82f6' }, + { id: 'docs', label: 'docs', color: '#8b5cf6' }, + { id: 'refactor', label: 'refactor', color: '#f59e0b' }, + { id: 'test', label: 'test', color: '#10b981' }, + { id: 'ci', label: 'ci' }, + { id: 'chore', label: 'chore' }, +] + +const FileTagsDemo: React.FC = () => { + const interactiveRef = useRef(null) + const readonlyRef = useRef(null) + + const [selectedIds, setSelectedIds] = useState(['bug', 'feature']) + + useEffect(() => { + const el = interactiveRef.current + if (!el) return + el.tags = ALL_TAGS + el.selectedIds = selectedIds + + const handler = (e: Event) => { + const { selectedIds: ids } = (e as CustomEvent<{ selectedIds: string[] }>).detail + setSelectedIds(ids) + console.log('tc-change:', ids) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + // Keep DOM in sync when React state changes (e.g. after event) + useEffect(() => { + const el = interactiveRef.current + if (el) el.selectedIds = selectedIds + }, [selectedIds]) + + useEffect(() => { + const el = readonlyRef.current + if (!el) return + el.tags = ALL_TAGS + el.selectedIds = ['bug', 'docs', 'test'] + }, []) + + return ( +
    +
    +
    +
    + + + Web Components + + + +
    + + {/* @ts-ignore */} + + {selectedIds.length > 0 && ( +
    + Selected: {selectedIds.join(', ')} +
    + )} +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default FileTagsDemo diff --git a/examples/src/web-components/FloatingLabelDemo.tsx b/examples/src/web-components/FloatingLabelDemo.tsx new file mode 100644 index 00000000..72a35002 --- /dev/null +++ b/examples/src/web-components/FloatingLabelDemo.tsx @@ -0,0 +1,84 @@ +import React from 'react' + +const FloatingLabelDemo: React.FC = () => ( +
    +
    +
    +
    + + + Web Components + + + +
    + +
    + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + -
    - Cancel - Submit Report -
    -
    - ` - - const backdrop = this.querySelector('[data-gc-backdrop]') as HTMLElement | null - backdrop?.addEventListener('click', () => this.emit('cancel')) - - this.querySelectorAll('input[name="gc-report-reason"]').forEach((input) => { - input.addEventListener('change', () => { - this.selectedReason = input.value - this.render() - }) - }) - - const textarea = this.querySelector('.gc-report-player-dialog-comment') as HTMLTextAreaElement | null - textarea?.addEventListener('input', () => { - this.comment = textarea.value - }) - - const cancel = this.querySelector('.gc-report-player-dialog-cancel') as HTMLElement | null - cancel?.addEventListener('click', () => this.emit('cancel')) - - const submit = this.querySelector('.gc-report-player-dialog-submit') as HTMLElement | null - submit?.addEventListener('click', () => { - if (!this.selectedReason) return - this.emit('submit', { reason: this.selectedReason, comment: this.comment }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ReportPlayerDialog - } -} diff --git a/game-components/src/ResetToDefaults.ts b/game-components/src/ResetToDefaults.ts deleted file mode 100644 index fc48b5b9..00000000 --- a/game-components/src/ResetToDefaults.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { SettingRowBase } from './SettingRowBase' - -const TAG_NAME = 'gc-reset-to-defaults' - -export class ResetToDefaults extends SettingRowBase { - - private confirming = false - - connectedCallback(): void { - if (!this.hasAttribute('row-label')) this.setAttribute('row-label', 'Reset to defaults') - super.connectedCallback() - } - - protected renderControl(): string { - const label = this.confirming ? 'Confirm reset' : 'Reset' - const variant = this.confirming ? 'danger' : 'default' - return ` -
    - ${label} - ${this.confirming ? `Cancel` : ''} -
    - ` - } - - protected bindControl(): void { - const btn = this.querySelector('.gc-setting-row-reset-btn') as HTMLElement | null - const cancel = this.querySelector('.gc-setting-row-reset-cancel') as HTMLElement | null - if (btn) { - btn.addEventListener('click', () => { - if (!this.confirming) { - this.confirming = true - this.renderRow() - } else { - this.confirming = false - this.emit('reset') - this.renderRow() - } - }) - } - if (cancel) { - cancel.addEventListener('click', () => { - this.confirming = false - this.renderRow() - }) - } - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ResetToDefaults - } -} diff --git a/game-components/src/ResourceBarBase.ts b/game-components/src/ResourceBarBase.ts deleted file mode 100644 index a746cb13..00000000 --- a/game-components/src/ResourceBarBase.ts +++ /dev/null @@ -1,128 +0,0 @@ -export abstract class ResourceBarBase extends HTMLElement { - - static get observedAttributes(): string[] { - return ['value', 'max', 'ghost', 'segments', 'show-text', 'label'] - } - - constructor() { - super() - } - - connectedCallback(): void { - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get value(): number { - const raw = this.getAttribute('value') - if (raw == null) return 0 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? 0 : parsed - } - set value(v: number) { - this.setAttribute('value', String(v)) - } - - get max(): number { - const raw = this.getAttribute('max') - if (raw == null) return 100 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed <= 0 ? 100 : parsed - } - set max(v: number) { - this.setAttribute('max', String(v)) - } - - get ghost(): number | null { - const raw = this.getAttribute('ghost') - if (raw == null) return null - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? null : parsed - } - set ghost(v: number | null) { - if (v == null) this.removeAttribute('ghost') - else this.setAttribute('ghost', String(v)) - } - - get segments(): number { - const raw = this.getAttribute('segments') - if (raw == null) return 1 - const parsed = parseInt(raw, 10) - return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed - } - set segments(v: number) { - this.setAttribute('segments', String(v)) - } - - get showText(): boolean { - return this.hasAttribute('show-text') - } - set showText(v: boolean) { - if (v) this.setAttribute('show-text', '') - else this.removeAttribute('show-text') - } - - get label(): string { - return this.getAttribute('label') ?? '' - } - set label(v: string) { - this.setAttribute('label', v) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private render(): void { - const max = this.max - const value = Math.max(0, Math.min(max, this.value)) - const pct = (value / max) * 100 - const ghost = this.ghost - const ghostPct = ghost != null ? Math.max(0, Math.min(max, ghost)) / max * 100 : 0 - const showGhost = ghost != null && ghostPct > pct - const segments = this.segments - const showText = this.showText - const label = this.label - - const segmentDividers = segments > 1 - ? Array.from({ length: segments - 1 }, (_, i) => { - const left = ((i + 1) / segments) * 100 - return `
    ` - }).join('') - : '' - - const labelRow = label - ? `
    - ${this.escape(label)} - ${Math.round(value)} / ${Math.round(max)} -
    ` - : '' - - const inlineText = showText && !label - ? `
    ${Math.round(value)} / ${Math.round(max)}
    ` - : '' - - const ghostMarkup = showGhost - ? `
    ` - : '' - - this.innerHTML = ` - ${labelRow} -
    - ${ghostMarkup} -
    -
    - ${segmentDividers} - ${inlineText} -
    - ` - } -} diff --git a/game-components/src/ResultScreen.ts b/game-components/src/ResultScreen.ts deleted file mode 100644 index 2c2ea660..00000000 --- a/game-components/src/ResultScreen.ts +++ /dev/null @@ -1,203 +0,0 @@ -const TAG_NAME = 'gc-result-screen' - -export type ResultScreenTitleColor = 'gold' | 'danger' | 'parch' - -export interface ResultStat { - label: string - value: string | number -} - -export interface ResultReward { - label: string - glyph?: string - amount?: number | string - color?: string -} - -export interface ResultAction { - id: string - label: string - variant?: 'default' | 'primary' | 'danger' | 'ghost' -} - -export interface ResultScreenEventMap { - action: CustomEvent<{ id: string }> -} - -export class ResultScreen extends HTMLElement { - - static get observedAttributes(): string[] { - return ['title-text', 'subtitle', 'title-color'] - } - - private _stats: ResultStat[] = [] - private _rewards: ResultReward[] = [] - private _actions: ResultAction[] = [] - - constructor() { - super() - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'region') - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get titleText(): string { - return this.getAttribute('title-text') ?? this.defaultTitleText() - } - set titleText(v: string) { - if (v) this.setAttribute('title-text', v) - else this.removeAttribute('title-text') - } - - get subtitle(): string { - return this.getAttribute('subtitle') ?? '' - } - set subtitle(v: string) { - if (v) this.setAttribute('subtitle', v) - else this.removeAttribute('subtitle') - } - - get titleColor(): ResultScreenTitleColor { - const raw = this.getAttribute('title-color') - if (raw === 'danger' || raw === 'parch' || raw === 'gold') return raw - return this.defaultTitleColor() - } - set titleColor(v: ResultScreenTitleColor) { - if (v) this.setAttribute('title-color', v) - else this.removeAttribute('title-color') - } - - get stats(): ResultStat[] { - return this._stats.slice() - } - set stats(v: ResultStat[]) { - this._stats = Array.isArray(v) ? v.slice() : [] - if (this.isConnected) this.render() - } - - get rewards(): ResultReward[] { - return this._rewards.slice() - } - set rewards(v: ResultReward[]) { - this._rewards = Array.isArray(v) ? v.slice() : [] - if (this.isConnected) this.render() - } - - get actions(): ResultAction[] { - return this._actions.slice() - } - set actions(v: ResultAction[]) { - this._actions = Array.isArray(v) ? v.slice() : [] - if (this.isConnected) this.render() - } - - protected defaultTitleText(): string { - return '' - } - - protected defaultTitleColor(): ResultScreenTitleColor { - return 'gold' - } - - protected eyebrowLabel(): string { - return 'Result' - } - - private emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private formatValue(v: string | number): string { - if (typeof v === 'number') return v.toLocaleString() - return v - } - - private render(): void { - const titleText = this.titleText - const subtitle = this.subtitle - const titleColor = this.titleColor - - const statMarkup = this._stats.map((stat) => { - return `
    - ${this.escape(stat.label)} - ${this.escape(this.formatValue(stat.value))} -
    ` - }).join('') - - const rewardMarkup = this._rewards.map((reward) => { - const colorAttr = reward.color ? ` style="color: ${this.escape(reward.color)}"` : '' - const glyphMarkup = reward.glyph ? `${this.escape(reward.glyph)}` : '' - const amountMarkup = reward.amount != null - ? `${this.escape(this.formatValue(reward.amount))}` - : '' - return `
    - ${glyphMarkup} - ${this.escape(reward.label)} - ${amountMarkup} -
    ` - }).join('') - - const actionMarkup = this._actions.map((action) => { - const variant = action.variant || 'default' - return `${this.escape(action.label)}` - }).join('') - - const subtitleMarkup = subtitle - ? `
    ${this.escape(subtitle)}
    ` - : '' - - const statsBlock = statMarkup - ? `
    ${statMarkup}
    ` - : '' - const rewardsBlock = rewardMarkup - ? `
    - Rewards -
    ${rewardMarkup}
    -
    ` - : '' - const actionsBlock = actionMarkup - ? `
    ${actionMarkup}
    ` - : '' - - this.innerHTML = ` -
    - ${this.escape(this.eyebrowLabel())} - ${this.escape(titleText)} -
    - ${subtitleMarkup} - ${statsBlock} - ${rewardsBlock} - ${actionsBlock} -
    - ` - - this.querySelectorAll('.gc-result-screen-action').forEach((el) => { - el.addEventListener('click', () => { - const id = el.dataset.id || '' - if (!id) return - this.emit('action', { id }) - }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ResultScreen - } -} diff --git a/game-components/src/RuneCorner.ts b/game-components/src/RuneCorner.ts deleted file mode 100644 index 6f607e11..00000000 --- a/game-components/src/RuneCorner.ts +++ /dev/null @@ -1,64 +0,0 @@ -const TAG_NAME = 'gc-rune-corner' - -export type RuneCornerPosition = 'tl' | 'tr' | 'bl' | 'br' - -export class RuneCorner extends HTMLElement { - - static get observedAttributes(): string[] { - return ['at', 'size'] - } - - private root: ShadowRoot - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - this.root.innerHTML = `` - } - - connectedCallback(): void { - if (!this.hasAttribute('at')) { - this.setAttribute('at', 'tl') - } - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) { - this.render() - } - } - - get at(): RuneCornerPosition { - return (this.getAttribute('at') as RuneCornerPosition) || 'tl' - } - set at(value: RuneCornerPosition) { - this.setAttribute('at', value) - } - - get size(): number | null { - const value = this.getAttribute('size') - if (value == null) return null - const parsed = parseFloat(value) - return Number.isNaN(parsed) ? null : parsed - } - set size(value: number | null) { - if (value == null) this.removeAttribute('size') - else this.setAttribute('size', String(value)) - } - - private render(): void { - const size = this.size - if (size != null) { - this.style.setProperty('--gc-rune-corner-size', `${size}px`) - } else { - this.style.removeProperty('--gc-rune-corner-size') - } - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: RuneCorner - } -} diff --git a/game-components/src/SafeArea.ts b/game-components/src/SafeArea.ts deleted file mode 100644 index 01e89dc7..00000000 --- a/game-components/src/SafeArea.ts +++ /dev/null @@ -1,45 +0,0 @@ -const TAG_NAME = 'gc-safe-area' - -export class SafeArea extends HTMLElement { - - static get observedAttributes(): string[] { - return ['extra'] - } - - private root: ShadowRoot - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - } - - connectedCallback(): void { - if (!this.root.firstChild) { - this.root.innerHTML = `` - } - this.applyExtra() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.applyExtra() - } - - get extra(): string { - return this.getAttribute('extra') ?? '' - } - set extra(value: string) { - if (value) this.setAttribute('extra', value) - else this.removeAttribute('extra') - } - - private applyExtra(): void { - const e = this.extra || '0px' - this.style.setProperty('--gc-safe-area-extra', e) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: SafeArea - } -} diff --git a/game-components/src/SaveSlotList.ts b/game-components/src/SaveSlotList.ts deleted file mode 100644 index f339688a..00000000 --- a/game-components/src/SaveSlotList.ts +++ /dev/null @@ -1,170 +0,0 @@ -const TAG_NAME = 'gc-save-slot-list' - -export type SaveSlotMode = 'load' | 'save' - -export interface SaveSlot { - id: string - name?: string - timestamp?: string - location?: string - level?: number - playtime?: string - empty?: boolean - autosave?: boolean -} - -export interface SaveSlotListEventMap { - select: CustomEvent<{ id: string }> - save: CustomEvent<{ id: string }> - load: CustomEvent<{ id: string }> - delete: CustomEvent<{ id: string }> -} - -export class SaveSlotList extends HTMLElement { - - static get observedAttributes(): string[] { - return ['selected-id', 'mode'] - } - - private _slots: SaveSlot[] = [] - - constructor() { - super() - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'listbox') - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get selectedId(): string { - return this.getAttribute('selected-id') ?? '' - } - set selectedId(v: string) { - if (v) this.setAttribute('selected-id', v) - else this.removeAttribute('selected-id') - } - - get mode(): SaveSlotMode { - const raw = this.getAttribute('mode') - return raw === 'save' ? 'save' : 'load' - } - set mode(v: SaveSlotMode) { - this.setAttribute('mode', v) - } - - get slots(): SaveSlot[] { - return this._slots.slice() - } - set slots(value: SaveSlot[]) { - this._slots = Array.isArray(value) ? value.slice() : [] - if (this.isConnected) this.render() - } - - private emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private render(): void { - const selected = this.selectedId - const mode = this.mode - - const rowsMarkup = this._slots.map((s) => { - const isSelected = s.id === selected - const cls = `gc-save-slot-list-row${isSelected ? ' is-selected' : ''}${s.empty ? ' is-empty' : ''}${s.autosave ? ' is-autosave' : ''}` - const tabindex = s.empty && mode === 'load' ? '-1' : '0' - - const nameMarkup = s.empty - ? `Empty Slot` - : `${this.escape(s.name || 'Save')}` - const autosaveBadge = s.autosave - ? `Auto` - : '' - - const meta: string[] = [] - if (!s.empty) { - if (typeof s.level === 'number') meta.push(`Lv ${s.level}`) - if (s.location) meta.push(`${this.escape(s.location)}`) - if (s.playtime) meta.push(`${this.escape(s.playtime)}`) - if (s.timestamp) meta.push(`${this.escape(s.timestamp)}`) - } - const metaBlock = meta.length ? `
    ${meta.join('')}
    ` : '' - - const actions: string[] = [] - if (mode === 'save') { - actions.push(``) - if (!s.empty && !s.autosave) { - actions.push(``) - } - } else { - if (!s.empty) { - actions.push(``) - if (!s.autosave) { - actions.push(``) - } - } - } - - return `
    -
    -
    - ${nameMarkup} - ${autosaveBadge} -
    - ${metaBlock} -
    -
    ${actions.join('')}
    -
    ` - }).join('') - - this.innerHTML = rowsMarkup - - this.querySelectorAll('.gc-save-slot-list-row').forEach((el) => { - const id = el.dataset.id || '' - el.addEventListener('click', (event) => { - if ((event.target as HTMLElement).closest('button')) return - this.selectedId = id - this.emit('select', { id }) - }) - el.addEventListener('keydown', (event) => { - if (event.key !== 'Enter' && event.key !== ' ') return - if ((event.target as HTMLElement).closest('button')) return - event.preventDefault() - this.selectedId = id - this.emit('select', { id }) - }) - }) - - this.querySelectorAll('.gc-save-slot-list-action').forEach((el) => { - el.addEventListener('click', (event) => { - event.stopPropagation() - const row = el.closest('.gc-save-slot-list-row') as HTMLElement | null - if (!row) return - const id = row.dataset.id || '' - const action = el.dataset.action || '' - if (action === 'save' || action === 'load' || action === 'delete') { - this.emit(action, { id }) - } - }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: SaveSlotList - } -} diff --git a/game-components/src/ScoreDisplay.ts b/game-components/src/ScoreDisplay.ts deleted file mode 100644 index 76473e82..00000000 --- a/game-components/src/ScoreDisplay.ts +++ /dev/null @@ -1,114 +0,0 @@ -const TAG_NAME = 'gc-score-display' - -export type ScoreDisplayAlign = 'left' | 'right' | 'center' - -export class ScoreDisplay extends HTMLElement { - - static get observedAttributes(): string[] { - return ['score', 'multiplier', 'label', 'align', 'font-size'] - } - - private root: ShadowRoot - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - this.root.innerHTML = `` - } - - connectedCallback(): void { - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get score(): number { - const raw = this.getAttribute('score') - if (raw == null) return 0 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? 0 : parsed - } - set score(value: number) { - this.setAttribute('score', String(value)) - } - - get multiplier(): number | null { - const raw = this.getAttribute('multiplier') - if (raw == null || raw === '') return null - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? null : parsed - } - set multiplier(value: number | null) { - if (value == null) this.removeAttribute('multiplier') - else this.setAttribute('multiplier', String(value)) - } - - get label(): string { - return this.getAttribute('label') || '' - } - set label(value: string) { - if (value) this.setAttribute('label', value) - else this.removeAttribute('label') - } - - get align(): ScoreDisplayAlign { - return (this.getAttribute('align') as ScoreDisplayAlign) || 'left' - } - set align(value: ScoreDisplayAlign) { - this.setAttribute('align', value) - } - - get fontSize(): number { - const raw = this.getAttribute('font-size') - if (raw == null) return 28 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed <= 0 ? 28 : parsed - } - set fontSize(value: number) { - this.setAttribute('font-size', String(value)) - } - - private render(): void { - const align = this.align - this.dataset.align = align - this.style.setProperty('--gc-score-display-font-size', `${this.fontSize}px`) - - const label = this.label - const score = this.score - const multiplier = this.multiplier - const showMultiplier = multiplier !== null && multiplier !== 1 - - const labelNode = label - ? `${this.escape(label)}` - : '' - - const multiplierNode = showMultiplier - ? `×${multiplier}` - : '' - - this.innerHTML = ` - ${labelNode} - - ${score.toLocaleString()} - ${multiplierNode} - - ` - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ScoreDisplay - } -} diff --git a/game-components/src/ScreenFlash.ts b/game-components/src/ScreenFlash.ts deleted file mode 100644 index 546115ec..00000000 --- a/game-components/src/ScreenFlash.ts +++ /dev/null @@ -1,119 +0,0 @@ -const TAG_NAME = 'gc-screen-flash' - -export interface ScreenFlashEventMap { - done: CustomEvent -} - -export class ScreenFlash extends HTMLElement { - - static get observedAttributes(): string[] { - return ['flash-color', 'flash-opacity', 'duration', 'trigger'] - } - - private root: ShadowRoot - private timer: number | null = null - private lastTrigger: string | null = null - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - this.root.innerHTML = `
    ` - } - - connectedCallback(): void { - this.applyStyle() - this.lastTrigger = this.getAttribute('trigger') - } - - disconnectedCallback(): void { - this.clearTimer() - } - - attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { - if (!this.isConnected) return - if (name === 'trigger') { - if (newValue !== null && newValue !== this.lastTrigger) { - this.lastTrigger = newValue - this.flash() - } - return - } - this.applyStyle() - } - - get flashColor(): string { - return this.getAttribute('flash-color') ?? '#ffffff' - } - set flashColor(value: string) { - this.setAttribute('flash-color', value) - } - - get flashOpacity(): number { - const raw = this.getAttribute('flash-opacity') - if (raw == null) return 1 - const parsed = parseFloat(raw) - if (Number.isNaN(parsed)) return 1 - return Math.max(0, Math.min(1, parsed)) - } - set flashOpacity(value: number) { - this.setAttribute('flash-opacity', String(value)) - } - - get duration(): number { - const raw = this.getAttribute('duration') - if (raw == null) return 200 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed <= 0 ? 200 : parsed - } - set duration(value: number) { - this.setAttribute('duration', String(value)) - } - - get trigger(): string | null { - return this.getAttribute('trigger') - } - set trigger(value: string | null) { - if (value == null) this.removeAttribute('trigger') - else this.setAttribute('trigger', value) - } - - private clearTimer(): void { - if (this.timer != null) { - window.clearTimeout(this.timer) - this.timer = null - } - } - - private emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - private applyStyle(): void { - const fill = this.root.querySelector('.gc-screen-flash-fill') as HTMLElement | null - if (!fill) return - fill.style.background = this.flashColor - fill.style.setProperty('--gc-screen-flash-duration', `${this.duration}ms`) - } - - private flash(): void { - this.applyStyle() - const fill = this.root.querySelector('.gc-screen-flash-fill') as HTMLElement | null - if (!fill) return - this.clearTimer() - fill.style.transition = 'none' - fill.style.opacity = String(this.flashOpacity) - void fill.offsetWidth - fill.style.transition = `opacity ${this.duration}ms ease-out` - fill.style.opacity = '0' - this.timer = window.setTimeout(() => { - this.timer = null - this.emit('done') - }, this.duration) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ScreenFlash - } -} diff --git a/game-components/src/ScrollText.ts b/game-components/src/ScrollText.ts deleted file mode 100644 index 848b13aa..00000000 --- a/game-components/src/ScrollText.ts +++ /dev/null @@ -1,55 +0,0 @@ -const TAG_NAME = 'gc-scroll-text' - -export class ScrollText extends HTMLElement { - - static get observedAttributes(): string[] { - return ['scroll-title'] - } - - private root: ShadowRoot - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - this.root.innerHTML = `` - } - - connectedCallback(): void { - this.syncTitle() - } - - attributeChangedCallback(): void { - if (this.isConnected) { - this.syncTitle() - } - } - - get scrollTitle(): string { - return this.getAttribute('scroll-title') || '' - } - set scrollTitle(value: string) { - if (value) this.setAttribute('scroll-title', value) - else this.removeAttribute('scroll-title') - } - - private syncTitle(): void { - const title = this.scrollTitle - let titleEl = this.querySelector(':scope > [data-gc-scroll-title]') as HTMLElement | null - if (title) { - if (!titleEl) { - titleEl = document.createElement('div') - titleEl.setAttribute('data-gc-scroll-title', '') - this.insertBefore(titleEl, this.firstChild) - } - titleEl.textContent = `✦ ${title}` - } else if (titleEl) { - titleEl.remove() - } - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ScrollText - } -} diff --git a/game-components/src/SelectRow.ts b/game-components/src/SelectRow.ts deleted file mode 100644 index 9b4e1ab8..00000000 --- a/game-components/src/SelectRow.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { SettingRowBase } from './SettingRowBase' - -const TAG_NAME = 'gc-select-row' - -export interface SelectOption { - value: string - label: string -} - -export class SelectRow extends SettingRowBase { - - static get observedAttributes(): string[] { - return [...SettingRowBase.observedAttributes, 'value'] - } - - private _options: SelectOption[] = [] - - get value(): string { - return this.getAttribute('value') ?? '' - } - set value(v: string) { - this.setAttribute('value', v) - } - - get options(): SelectOption[] { - return this._options.slice() - } - set options(values: SelectOption[]) { - this._options = Array.isArray(values) ? values.slice() : [] - if (this.isConnected) this.renderRow() - } - - protected renderControl(): string { - const value = this.value - const optsMarkup = this._options.map(opt => { - const selected = opt.value === value ? 'selected' : '' - return `` - }).join('') - return `` - } - - protected bindControl(): void { - const select = this.querySelector('.gc-setting-row-select') as HTMLSelectElement | null - if (!select) return - select.addEventListener('change', () => { - const v = select.value - this.setAttribute('value', v) - this.emit('change', { value: v }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: SelectRow - } -} diff --git a/game-components/src/SettingRowBase.ts b/game-components/src/SettingRowBase.ts deleted file mode 100644 index e0bbd522..00000000 --- a/game-components/src/SettingRowBase.ts +++ /dev/null @@ -1,64 +0,0 @@ -export abstract class SettingRowBase extends HTMLElement { - - static get observedAttributes(): string[] { - return ['row-label', 'description'] - } - - constructor() { - super() - } - - connectedCallback(): void { - this.classList.add('gc-setting-row') - this.renderRow() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.renderRow() - } - - get rowLabel(): string { - return this.getAttribute('row-label') ?? '' - } - set rowLabel(v: string) { - this.setAttribute('row-label', v) - } - - get description(): string { - return this.getAttribute('description') ?? '' - } - set description(v: string) { - this.setAttribute('description', v) - } - - protected abstract renderControl(): string - - protected escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - protected emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - protected renderRow(): void { - const label = this.rowLabel - const description = this.description - const descMarkup = description ? `
    ${this.escape(description)}
    ` : '' - this.innerHTML = ` -
    -
    ${this.escape(label)}
    - ${descMarkup} -
    -
    ${this.renderControl()}
    - ` - this.bindControl() - } - - protected abstract bindControl(): void -} diff --git a/game-components/src/SettingsCategoryList.ts b/game-components/src/SettingsCategoryList.ts deleted file mode 100644 index c5378dc0..00000000 --- a/game-components/src/SettingsCategoryList.ts +++ /dev/null @@ -1,110 +0,0 @@ -const TAG_NAME = 'gc-settings-category-list' - -export interface SettingsCategory { - id: string - label: string - icon?: string -} - -export interface SettingsCategoryListEventMap { - select: CustomEvent<{ id: string }> -} - -export class SettingsCategoryList extends HTMLElement { - - static get observedAttributes(): string[] { - return ['selected-id'] - } - - private _categories: SettingsCategory[] = [] - - constructor() { - super() - } - - connectedCallback(): void { - this.ensureSkeleton() - this.renderNav() - } - - attributeChangedCallback(): void { - if (!this.isConnected) return - this.renderNav() - } - - get categories(): SettingsCategory[] { - return this._categories - } - set categories(value: SettingsCategory[]) { - this._categories = Array.isArray(value) ? value : [] - if (this.isConnected) this.renderNav() - } - - get selectedId(): string { - return this.getAttribute('selected-id') ?? '' - } - set selectedId(value: string) { - if (value) this.setAttribute('selected-id', value) - else this.removeAttribute('selected-id') - } - - private emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private ensureSkeleton(): void { - if (this.querySelector(':scope > [data-gc-settings-nav]')) return - const existing = Array.from(this.childNodes) - const nav = document.createElement('nav') - nav.dataset.gcSettingsNav = '' - nav.setAttribute('role', 'tablist') - nav.className = 'gc-settings-category-list-nav' - const body = document.createElement('div') - body.dataset.gcSettingsContent = '' - body.setAttribute('role', 'tabpanel') - body.className = 'gc-settings-category-list-body' - existing.forEach((c) => body.appendChild(c)) - this.replaceChildren(nav, body) - } - - private renderNav(): void { - const nav = this.querySelector(':scope > [data-gc-settings-nav]') as HTMLElement | null - if (!nav) return - const selected = this.selectedId - nav.innerHTML = this._categories.map((cat) => { - const isSelected = cat.id === selected - const iconMarkup = cat.icon - ? (cat.icon.startsWith('bi-') || cat.icon.startsWith('bi ') - ? `` - : `${this.escape(cat.icon)}`) - : '' - const cls = `gc-settings-category${isSelected ? ' is-selected' : ''}` - const ariaCurrent = isSelected ? ' aria-current="true"' : '' - return `` - }).join('') - - nav.querySelectorAll('.gc-settings-category').forEach((el) => { - el.addEventListener('click', () => { - const id = el.dataset.id || '' - if (!id || id === this.selectedId) return - this.selectedId = id - this.emit('select', { id }) - }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: SettingsCategoryList - } -} diff --git a/game-components/src/ShakeContainer.ts b/game-components/src/ShakeContainer.ts deleted file mode 100644 index 6379b357..00000000 --- a/game-components/src/ShakeContainer.ts +++ /dev/null @@ -1,101 +0,0 @@ -const TAG_NAME = 'gc-shake-container' - -export class ShakeContainer extends HTMLElement { - - static get observedAttributes(): string[] { - return ['trigger', 'intensity', 'duration'] - } - - private root: ShadowRoot - private rafHandle: number | null = null - private startedAt: number = 0 - private lastTrigger: string | null = null - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - this.root.innerHTML = `
    ` - } - - connectedCallback(): void { - this.lastTrigger = this.getAttribute('trigger') - } - - disconnectedCallback(): void { - this.stopShake() - } - - attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void { - if (!this.isConnected) return - if (name === 'trigger' && newValue !== this.lastTrigger) { - this.lastTrigger = newValue - this.startShake() - } - } - - get trigger(): string | null { - return this.getAttribute('trigger') - } - set trigger(value: string | null) { - if (value == null) this.removeAttribute('trigger') - else this.setAttribute('trigger', value) - } - - get intensity(): number { - const raw = this.getAttribute('intensity') - if (raw == null) return 8 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed < 0 ? 8 : parsed - } - set intensity(value: number) { - this.setAttribute('intensity', String(value)) - } - - get duration(): number { - const raw = this.getAttribute('duration') - if (raw == null) return 350 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed <= 0 ? 350 : parsed - } - set duration(value: number) { - this.setAttribute('duration', String(value)) - } - - private stopShake(): void { - if (this.rafHandle != null) { - cancelAnimationFrame(this.rafHandle) - this.rafHandle = null - } - const inner = this.root.querySelector('.gc-shake-container-inner') as HTMLElement | null - if (inner) inner.style.transform = '' - } - - private startShake(): void { - this.stopShake() - this.startedAt = performance.now() - const inner = this.root.querySelector('.gc-shake-container-inner') as HTMLElement | null - if (!inner) return - const tick = (now: number) => { - const elapsed = now - this.startedAt - const total = this.duration - if (elapsed >= total) { - inner.style.transform = '' - this.rafHandle = null - return - } - const decay = 1 - elapsed / total - const range = this.intensity * decay - const x = (Math.random() * 2 - 1) * range - const y = (Math.random() * 2 - 1) * range - inner.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px)` - this.rafHandle = requestAnimationFrame(tick) - } - this.rafHandle = requestAnimationFrame(tick) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ShakeContainer - } -} diff --git a/game-components/src/ShopPanel.ts b/game-components/src/ShopPanel.ts deleted file mode 100644 index de155cdc..00000000 --- a/game-components/src/ShopPanel.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { InventoryItem } from './ItemSlot' - -const TAG_NAME = 'gc-shop-panel' - -export interface ShopItem { - item: InventoryItem - price: number - discount?: number - soldOut?: boolean -} - -export interface ShopPanelEventMap { - buy: CustomEvent<{ id: string }> - sell: CustomEvent<{ id: string }> -} - -export class ShopPanel extends HTMLElement { - - static get observedAttributes(): string[] { - return ['sell-mode', 'currency', 'currency-icon'] - } - - private _items: ShopItem[] = [] - - constructor() { - super() - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'group') - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get sellMode(): boolean { - return this.hasAttribute('sell-mode') - } - set sellMode(v: boolean) { - if (v) this.setAttribute('sell-mode', '') - else this.removeAttribute('sell-mode') - } - - get currency(): number | null { - const raw = this.getAttribute('currency') - if (raw == null) return null - const parsed = parseFloat(raw) - return Number.isFinite(parsed) ? parsed : null - } - set currency(v: number | null) { - if (v == null) this.removeAttribute('currency') - else this.setAttribute('currency', String(v)) - } - - get currencyIcon(): string { - return this.getAttribute('currency-icon') ?? '◆' - } - set currencyIcon(v: string) { - if (v) this.setAttribute('currency-icon', v) - else this.removeAttribute('currency-icon') - } - - get items(): ShopItem[] { - return this._items.slice() - } - set items(value: ShopItem[]) { - this._items = Array.isArray(value) ? value.slice() : [] - if (this.isConnected) this.render() - } - - private emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private render(): void { - const sellMode = this.sellMode - const currency = this.currency - const currencyIcon = this.currencyIcon - const action = sellMode ? 'sell' : 'buy' - const actionLabel = sellMode ? 'Sell' : 'Buy' - - const headerCurrency = currency != null - ? `
    ${this.escape(currencyIcon)} ${currency.toLocaleString()}
    ` - : '' - - const rowsMarkup = this._items.map((s) => { - const finalPrice = s.discount && s.discount > 0 - ? Math.round(s.price * (1 - s.discount)) - : s.price - const cantAfford = !sellMode && currency != null && currency < finalPrice - const disabled = !!s.soldOut || cantAfford - const cls = `gc-shop-panel-row${s.soldOut ? ' is-sold-out' : ''}${cantAfford ? ' is-unaffordable' : ''}` - const icon = s.item.icon || '◆' - const name = s.item.name ?? s.item.id - const discountMarkup = (s.discount && s.discount > 0) - ? `-${Math.round(s.discount * 100)}%` - : '' - const priceMarkup = ` - - ${this.escape(currencyIcon)} - ${s.discount && s.discount > 0 ? `${s.price.toLocaleString()}` : ''} - ${finalPrice.toLocaleString()} - - ` - const btnLabel = s.soldOut ? 'Sold' : actionLabel - return `
    - ${this.escape(icon)} - ${this.escape(name)} - ${discountMarkup} - ${priceMarkup} - -
    ` - }).join('') - - this.innerHTML = ` -
    - ${sellMode ? 'Sell' : 'Buy'} - ${headerCurrency} -
    -
    ${rowsMarkup}
    - ` - - this.querySelectorAll('.gc-shop-panel-row-action').forEach((el) => { - el.addEventListener('click', () => { - const row = el.closest('.gc-shop-panel-row') as HTMLElement | null - if (!row) return - const id = row.dataset.id || '' - const a = el.dataset.action || 'buy' - this.emit(a, { id }) - }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ShopPanel - } -} diff --git a/game-components/src/SkillBar.ts b/game-components/src/SkillBar.ts deleted file mode 100644 index fc55dd15..00000000 --- a/game-components/src/SkillBar.ts +++ /dev/null @@ -1,141 +0,0 @@ -const TAG_NAME = 'gc-skill-bar' - -export interface SkillSlot { - id: string - icon?: string - hotkey?: string - cooldown?: number - remaining?: number - charges?: number - disabled?: boolean - selected?: boolean -} - -export interface SkillBarEventMap { - activate: CustomEvent<{ id: string }> -} - -export class SkillBar extends HTMLElement { - - static get observedAttributes(): string[] { - return ['slot-size', 'gap'] - } - - private _slots: SkillSlot[] = [] - - constructor() { - super() - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'toolbar') - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get slots(): SkillSlot[] { - return this._slots.slice() - } - set slots(value: SkillSlot[]) { - this._slots = Array.isArray(value) ? value.slice() : [] - if (this.isConnected) this.render() - } - - get slotSize(): number { - const raw = this.getAttribute('slot-size') - if (raw == null) return 56 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed <= 0 ? 56 : parsed - } - set slotSize(value: number) { - if (value > 0) this.setAttribute('slot-size', String(value)) - else this.removeAttribute('slot-size') - } - - get gap(): string { - return this.getAttribute('gap') ?? '6px' - } - set gap(value: string) { - if (value) this.setAttribute('gap', value) - else this.removeAttribute('gap') - } - - private emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private renderGlyph(icon?: string): string { - if (!icon) return '' - if (icon.startsWith('bi-') || icon.startsWith('bi ')) { - const cls = icon.startsWith('bi ') ? icon : `bi ${icon}` - return `` - } - return `${this.escape(icon)}` - } - - private render(): void { - const size = this.slotSize - this.style.setProperty('--gc-skill-bar-size', `${size}px`) - this.style.setProperty('--gc-skill-bar-glyph-size', `${Math.round(size * 0.42)}px`) - this.style.setProperty('--gc-skill-bar-gap', this.gap) - - const cellsHTML = this._slots.map((slot, index) => { - const cdPct = slot.remaining != null && slot.cooldown && slot.cooldown > 0 - ? Math.max(0, Math.min(1, slot.remaining / slot.cooldown)) - : null - const cooldownMarkup = cdPct != null && cdPct > 0 - ? `
    - ${Math.ceil(slot.remaining!)} -
    ` - : '' - const hotkeyMarkup = slot.hotkey - ? `${this.escape(slot.hotkey)}` - : '' - const chargesMarkup = slot.charges != null && slot.charges > 0 - ? `${slot.charges}` - : '' - const cls = `gc-skill-bar-cell${slot.selected ? ' is-selected' : ''}${slot.disabled ? ' is-disabled' : ''}` - return `
    - ${this.renderGlyph(slot.icon)} - ${hotkeyMarkup} - ${chargesMarkup} - ${cooldownMarkup} -
    ` - }).join('') - this.innerHTML = `
    ${cellsHTML}
    ` - - this.querySelectorAll('.gc-skill-bar-cell').forEach((el) => { - const index = parseInt(el.dataset.index ?? '-1', 10) - const slot = this._slots[index] - if (!slot) return - const activate = () => { - if (slot.disabled) return - this.emit('activate', { id: slot.id }) - } - el.addEventListener('click', activate) - el.addEventListener('keydown', (event) => { - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - activate() - }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: SkillBar - } -} diff --git a/game-components/src/SkillTree.ts b/game-components/src/SkillTree.ts deleted file mode 100644 index 53fcec3a..00000000 --- a/game-components/src/SkillTree.ts +++ /dev/null @@ -1,183 +0,0 @@ -const TAG_NAME = 'gc-skill-tree' - -export interface SkillNode { - id: string - x: number - y: number - label?: string - icon?: string - locked?: boolean - unlocked?: boolean - rank?: number - maxRank?: number - description?: string -} - -export interface SkillTreeEdge { - from: string - to: string -} - -export interface SkillTreeEventMap { - select: CustomEvent<{ id: string }> - unlock: CustomEvent<{ id: string }> -} - -export class SkillTree extends HTMLElement { - - static get observedAttributes(): string[] { - return ['selected-id', 'points', 'width', 'height'] - } - - private _nodes: SkillNode[] = [] - private _edges: SkillTreeEdge[] = [] - - constructor() { - super() - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'tree') - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - private numberAttr(name: string, fallback: number): number { - const raw = this.getAttribute(name) - if (raw == null) return fallback - const parsed = parseFloat(raw) - return Number.isFinite(parsed) ? parsed : fallback - } - - get selectedId(): string { - return this.getAttribute('selected-id') ?? '' - } - set selectedId(v: string) { - if (v) this.setAttribute('selected-id', v) - else this.removeAttribute('selected-id') - } - - get points(): number | null { - const raw = this.getAttribute('points') - if (raw == null) return null - const parsed = parseFloat(raw) - return Number.isFinite(parsed) ? parsed : null - } - set points(v: number | null) { - if (v == null) this.removeAttribute('points') - else this.setAttribute('points', String(v)) - } - - get width(): number { return this.numberAttr('width', 600) } - set width(v: number) { this.setAttribute('width', String(v)) } - - get height(): number { return this.numberAttr('height', 400) } - set height(v: number) { this.setAttribute('height', String(v)) } - - get nodes(): SkillNode[] { - return this._nodes.slice() - } - set nodes(value: SkillNode[]) { - this._nodes = Array.isArray(value) ? value.slice() : [] - if (this.isConnected) this.render() - } - - get edges(): SkillTreeEdge[] { - return this._edges.slice() - } - set edges(value: SkillTreeEdge[]) { - this._edges = Array.isArray(value) ? value.slice() : [] - if (this.isConnected) this.render() - } - - private emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private render(): void { - const w = this.width - const h = this.height - const selected = this.selectedId - const points = this.points - const nodeMap = new Map(this._nodes.map((n) => [n.id, n])) - - const edgesMarkup = this._edges.map((e) => { - const a = nodeMap.get(e.from) - const b = nodeMap.get(e.to) - if (!a || !b) return '' - const unlocked = a.unlocked && b.unlocked - const cls = `gc-skill-tree-edge${unlocked ? ' is-unlocked' : ''}` - return `` - }).join('') - - const nodesMarkup = this._nodes.map((n) => { - const isSelected = n.id === selected - const stateCls = n.locked ? ' is-locked' : (n.unlocked ? ' is-unlocked' : '') - const cls = `gc-skill-tree-node${isSelected ? ' is-selected' : ''}${stateCls}` - const tabindex = n.locked ? '-1' : '0' - const rankMarkup = (typeof n.rank === 'number' && typeof n.maxRank === 'number') - ? `
    ${n.rank}/${n.maxRank}
    ` - : '' - const labelMarkup = n.label - ? `
    ${this.escape(n.label)}
    ` - : '' - const icon = n.locked ? '🔒' : (n.icon || '✦') - return `
    -
    ${this.escape(icon)}
    - ${labelMarkup} - ${rankMarkup} -
    ` - }).join('') - - const pointsMarkup = points != null - ? `
    Points${points}
    ` - : '' - - this.innerHTML = ` - ${pointsMarkup} -
    - - ${nodesMarkup} -
    - ` - - this.querySelectorAll('.gc-skill-tree-node').forEach((el) => { - const id = el.dataset.id || '' - const node = nodeMap.get(id) - if (!node || node.locked) return - el.addEventListener('click', () => { - this.selectedId = id - this.emit('select', { id }) - }) - el.addEventListener('dblclick', () => { - this.selectedId = id - this.emit('unlock', { id }) - }) - el.addEventListener('keydown', (event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - this.selectedId = id - this.emit('select', { id }) - } - }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: SkillTree - } -} diff --git a/game-components/src/Speedometer.ts b/game-components/src/Speedometer.ts deleted file mode 100644 index 2354a9e8..00000000 --- a/game-components/src/Speedometer.ts +++ /dev/null @@ -1,152 +0,0 @@ -const TAG_NAME = 'gc-speedometer' - -export class Speedometer extends HTMLElement { - - static get observedAttributes(): string[] { - return ['value', 'max', 'rpm', 'unit', 'gear', 'size'] - } - - constructor() { - super() - } - - connectedCallback(): void { - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get value(): number { - const raw = this.getAttribute('value') - if (raw == null) return 0 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? 0 : parsed - } - set value(v: number) { - this.setAttribute('value', String(v)) - } - - get max(): number { - const raw = this.getAttribute('max') - if (raw == null) return 220 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed <= 0 ? 220 : parsed - } - set max(v: number) { - this.setAttribute('max', String(v)) - } - - get rpm(): number | null { - const raw = this.getAttribute('rpm') - if (raw == null) return null - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? null : parsed - } - set rpm(v: number | null) { - if (v == null) this.removeAttribute('rpm') - else this.setAttribute('rpm', String(v)) - } - - get unit(): string { - return this.getAttribute('unit') ?? 'KM/H' - } - set unit(v: string) { - this.setAttribute('unit', v) - } - - get gear(): string { - return this.getAttribute('gear') ?? '' - } - set gear(v: string) { - this.setAttribute('gear', v) - } - - get size(): number { - const raw = this.getAttribute('size') - if (raw == null) return 160 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed <= 0 ? 160 : parsed - } - set size(v: number) { - this.setAttribute('size', String(v)) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private render(): void { - const size = this.size - this.style.setProperty('--gc-speedo-size', `${size}px`) - - const value = this.value - const max = this.max - const pct = Math.max(0, Math.min(1, value / max)) - const danger = pct >= 0.85 - this.dataset.danger = String(danger) - - const rpm = this.rpm - const unit = this.unit - const gear = this.gear - - const radius = 0.45 - const stroke = 0.08 - const total = Math.PI - const cx = 50 - const cy = 60 - const start = { x: cx + radius * 100 * Math.cos(Math.PI), y: cy + radius * 100 * Math.sin(Math.PI) } - const end = { x: cx + radius * 100 * Math.cos(0), y: cy + radius * 100 * Math.sin(0) } - const trackPath = `M ${start.x} ${start.y} A ${radius * 100} ${radius * 100} 0 0 1 ${end.x} ${end.y}` - - const fillEndAngle = Math.PI - total * pct - const fillEnd = { x: cx + radius * 100 * Math.cos(fillEndAngle), y: cy + radius * 100 * Math.sin(fillEndAngle) } - const fillPath = `M ${start.x} ${start.y} A ${radius * 100} ${radius * 100} 0 0 1 ${fillEnd.x} ${fillEnd.y}` - - const ticks = Array.from({ length: 9 }, (_, i) => { - const t = i / 8 - const angle = Math.PI - total * t - const r1 = radius * 100 - const r2 = (radius - 0.05) * 100 - const x1 = cx + r1 * Math.cos(angle) - const y1 = cy + r1 * Math.sin(angle) - const x2 = cx + r2 * Math.cos(angle) - const y2 = cy + r2 * Math.sin(angle) - return `` - }).join('') - - const rpmRow = rpm != null - ? `
    ${Math.round(rpm)} RPM
    ` - : '' - - const gearRow = gear - ? `
    ${this.escape(gear)}
    ` - : '' - - this.innerHTML = ` - -
    - ${gearRow} -
    ${Math.round(value)}
    -
    ${this.escape(unit)}
    - ${rpmRow} -
    - ` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: Speedometer - } -} diff --git a/game-components/src/Stack.ts b/game-components/src/Stack.ts deleted file mode 100644 index 23fd22ba..00000000 --- a/game-components/src/Stack.ts +++ /dev/null @@ -1,87 +0,0 @@ -const TAG_NAME = 'gc-stack' - -export class Stack extends HTMLElement { - - static get observedAttributes(): string[] { - return ['direction', 'gap', 'align', 'justify', 'wrap', 'inline'] - } - - private root: ShadowRoot - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - } - - connectedCallback(): void { - if (!this.root.firstChild) { - this.root.innerHTML = `` - } - this.applyLayout() - } - - attributeChangedCallback(): void { - if (this.isConnected) { - this.applyLayout() - } - } - - get direction(): 'vertical' | 'horizontal' { - return (this.getAttribute('direction') as 'vertical' | 'horizontal') ?? 'vertical' - } - set direction(value: 'vertical' | 'horizontal') { - this.setAttribute('direction', value) - } - - get gap(): string { - return this.getAttribute('gap') ?? '' - } - set gap(value: string) { - this.setAttribute('gap', value) - } - - get align(): string { - return this.getAttribute('align') ?? '' - } - set align(value: string) { - this.setAttribute('align', value) - } - - get justify(): string { - return this.getAttribute('justify') ?? '' - } - set justify(value: string) { - this.setAttribute('justify', value) - } - - get wrap(): boolean { - return this.hasAttribute('wrap') - } - set wrap(value: boolean) { - if (value) this.setAttribute('wrap', '') - else this.removeAttribute('wrap') - } - - get inline(): boolean { - return this.hasAttribute('inline') - } - set inline(value: boolean) { - if (value) this.setAttribute('inline', '') - else this.removeAttribute('inline') - } - - private applyLayout(): void { - this.style.display = this.inline ? 'inline-flex' : 'flex' - this.style.flexDirection = this.direction === 'horizontal' ? 'row' : 'column' - this.style.gap = this.gap || '0px' - this.style.alignItems = this.align || 'stretch' - this.style.justifyContent = this.justify || 'flex-start' - this.style.flexWrap = this.wrap ? 'wrap' : 'nowrap' - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: Stack - } -} diff --git a/game-components/src/StaminaBar.ts b/game-components/src/StaminaBar.ts deleted file mode 100644 index 718d25f0..00000000 --- a/game-components/src/StaminaBar.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ResourceBarBase } from './ResourceBarBase' - -const TAG_NAME = 'gc-stamina-bar' - -export class StaminaBar extends ResourceBarBase {} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: StaminaBar - } -} diff --git a/game-components/src/StatRow.ts b/game-components/src/StatRow.ts deleted file mode 100644 index 400341a3..00000000 --- a/game-components/src/StatRow.ts +++ /dev/null @@ -1,99 +0,0 @@ -const TAG_NAME = 'gc-stat-row' - -export class StatRow extends HTMLElement { - - static get observedAttributes(): string[] { - return ['label', 'value', 'accent', 'trend'] - } - - constructor() { - super() - } - - connectedCallback(): void { - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get label(): string { - return this.getAttribute('label') ?? '' - } - set label(v: string) { - if (v) this.setAttribute('label', v) - else this.removeAttribute('label') - } - - get value(): string { - return this.getAttribute('value') ?? '' - } - set value(v: string | number) { - const text = typeof v === 'number' ? String(v) : v - if (text) this.setAttribute('value', text) - else this.removeAttribute('value') - } - - get accent(): string { - return this.getAttribute('accent') ?? '' - } - set accent(v: string) { - if (v) this.setAttribute('accent', v) - else this.removeAttribute('accent') - } - - get trend(): number | null { - const raw = this.getAttribute('trend') - if (raw == null || raw === '') return null - const parsed = parseFloat(raw) - return Number.isFinite(parsed) ? parsed : null - } - set trend(v: number | null) { - if (v == null) this.removeAttribute('trend') - else this.setAttribute('trend', String(v)) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private render(): void { - const accent = this.accent - if (accent) this.style.setProperty('--gc-stat-row-accent', accent) - else this.style.removeProperty('--gc-stat-row-accent') - - const trend = this.trend - let trendMarkup = '' - if (trend != null && trend !== 0) { - const isUp = trend > 0 - const cls = `gc-stat-row-trend is-${isUp ? 'up' : 'down'}` - const glyph = isUp ? '▲' : '▼' - const formatted = Math.abs(trend).toLocaleString() - trendMarkup = `${glyph} ${formatted}` - } - - const formattedValue = this.value - const numeric = parseFloat(formattedValue) - const valueText = Number.isFinite(numeric) && String(numeric) === formattedValue - ? numeric.toLocaleString() - : formattedValue - - this.innerHTML = ` - ${this.escape(this.label)} - ${this.escape(valueText)} - ${trendMarkup} - ` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: StatRow - } -} diff --git a/game-components/src/StatsScreen.ts b/game-components/src/StatsScreen.ts deleted file mode 100644 index 03a07846..00000000 --- a/game-components/src/StatsScreen.ts +++ /dev/null @@ -1,110 +0,0 @@ -const TAG_NAME = 'gc-stats-screen' - -export interface StatsScreenStat { - label: string - value: string | number -} - -export interface StatsSection { - title: string - stats: StatsScreenStat[] -} - -export class StatsScreen extends HTMLElement { - - static get observedAttributes(): string[] { - return ['screen-title', 'summary'] - } - - private _sections: StatsSection[] = [] - - constructor() { - super() - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'region') - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get screenTitle(): string { - return this.getAttribute('screen-title') ?? 'Stats' - } - set screenTitle(v: string) { - if (v) this.setAttribute('screen-title', v) - else this.removeAttribute('screen-title') - } - - get summary(): string { - return this.getAttribute('summary') ?? '' - } - set summary(v: string) { - if (v) this.setAttribute('summary', v) - else this.removeAttribute('summary') - } - - get sections(): StatsSection[] { - return this._sections.slice() - } - set sections(v: StatsSection[]) { - this._sections = Array.isArray(v) ? v.slice() : [] - if (this.isConnected) this.render() - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private formatValue(v: string | number): string { - if (typeof v === 'number') return v.toLocaleString() - return v - } - - private render(): void { - const sectionsMarkup = this._sections.map((section) => { - const statsMarkup = section.stats.map((stat) => { - return `
    - ${this.escape(stat.label)} - ${this.escape(this.formatValue(stat.value))} -
    ` - }).join('') - - return `
    - ${this.escape(section.title)} -
    ${statsMarkup}
    -
    ` - }).join('') - - const summary = this.summary - const summaryMarkup = summary - ? `
    ${this.escape(summary)}
    ` - : '' - - this.innerHTML = ` -
    -
    - Statistics - ${this.escape(this.screenTitle)} -
    - ${summaryMarkup} -
    -
    ${sectionsMarkup}
    -
    - ` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: StatsScreen - } -} diff --git a/game-components/src/Subtitle.ts b/game-components/src/Subtitle.ts deleted file mode 100644 index 575bea80..00000000 --- a/game-components/src/Subtitle.ts +++ /dev/null @@ -1,120 +0,0 @@ -const TAG_NAME = 'gc-subtitle' - -export type SubtitleAlign = 'left' | 'right' | 'center' - -export class Subtitle extends HTMLElement { - - static get observedAttributes(): string[] { - return ['text', 'speaker', 'boxed', 'align', 'font-size', 'max-width'] - } - - private root: ShadowRoot - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - this.root.innerHTML = `` - } - - connectedCallback(): void { - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) { - this.render() - } - } - - get text(): string { - return this.getAttribute('text') || '' - } - set text(value: string) { - if (value) this.setAttribute('text', value) - else this.removeAttribute('text') - } - - get speaker(): string { - return this.getAttribute('speaker') || '' - } - set speaker(value: string) { - if (value) this.setAttribute('speaker', value) - else this.removeAttribute('speaker') - } - - get boxed(): boolean { - return this.hasAttribute('boxed') - } - set boxed(value: boolean) { - if (value) this.setAttribute('boxed', '') - else this.removeAttribute('boxed') - } - - get align(): SubtitleAlign { - return (this.getAttribute('align') as SubtitleAlign) || 'center' - } - set align(value: SubtitleAlign) { - this.setAttribute('align', value) - } - - get fontSize(): number | null { - const value = this.getAttribute('font-size') - if (value == null) return null - const parsed = parseFloat(value) - return Number.isNaN(parsed) ? null : parsed - } - set fontSize(value: number | null) { - if (value == null) this.removeAttribute('font-size') - else this.setAttribute('font-size', String(value)) - } - - get maxWidth(): number | null { - const value = this.getAttribute('max-width') - if (value == null) return null - const parsed = parseFloat(value) - return Number.isNaN(parsed) ? null : parsed - } - set maxWidth(value: number | null) { - if (value == null) this.removeAttribute('max-width') - else this.setAttribute('max-width', String(value)) - } - - private render(): void { - const fs = this.fontSize - if (fs != null) { - this.style.setProperty('--gc-subtitle-font-size', `${fs}px`) - } else { - this.style.removeProperty('--gc-subtitle-font-size') - } - - const mw = this.maxWidth - if (mw != null) { - this.style.setProperty('--gc-subtitle-max-width', `${mw}px`) - } else { - this.style.removeProperty('--gc-subtitle-max-width') - } - - const text = this.text - const speaker = this.speaker - const speakerHtml = speaker - ? `
    ${this.escape(speaker)}
    ` - : '' - const textHtml = `
    ${this.escape(text)}
    ` - this.innerHTML = `${speakerHtml}${textHtml}` - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: Subtitle - } -} diff --git a/game-components/src/TabBar.ts b/game-components/src/TabBar.ts deleted file mode 100644 index 14347295..00000000 --- a/game-components/src/TabBar.ts +++ /dev/null @@ -1,100 +0,0 @@ -const TAG_NAME = 'gc-tab-bar' - -export type TabBarSize = 'sm' | 'md' - -export interface TabItem { - id: string - label: string - icon?: string -} - -export interface TabBarEventMap { - change: CustomEvent<{ id: string }> -} - -export class TabBar extends HTMLElement { - - static get observedAttributes(): string[] { - return ['active-id', 'size'] - } - - private _tabs: TabItem[] = [] - - constructor() { - super() - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'tablist') - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get tabs(): TabItem[] { - return this._tabs - } - set tabs(value: TabItem[]) { - this._tabs = Array.isArray(value) ? value : [] - if (this.isConnected) this.render() - } - - get activeId(): string { - return this.getAttribute('active-id') ?? '' - } - set activeId(value: string) { - if (value) this.setAttribute('active-id', value) - else this.removeAttribute('active-id') - } - - get size(): TabBarSize { - return (this.getAttribute('size') as TabBarSize) || 'md' - } - set size(value: TabBarSize) { - this.setAttribute('size', value) - } - - private emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private render(): void { - const active = this.activeId - const parts = this._tabs.map((tab) => { - const isActive = tab.id === active - const iconMarkup = tab.icon - ? (tab.icon.startsWith('bi-') || tab.icon.startsWith('bi ') - ? `` - : `${this.escape(tab.icon)}`) - : '' - return `` - }) - this.innerHTML = parts.join('') - this.querySelectorAll('.gc-tab-bar-tab').forEach((btn) => { - btn.addEventListener('click', () => { - const id = btn.dataset.id || '' - if (id && id !== this.activeId) { - this.activeId = id - this.emit('change', { id }) - } - }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: TabBar - } -} diff --git a/game-components/src/Title.ts b/game-components/src/Title.ts deleted file mode 100644 index 8c00d08b..00000000 --- a/game-components/src/Title.ts +++ /dev/null @@ -1,52 +0,0 @@ -const TAG_NAME = 'gc-title' - -export class Title extends HTMLElement { - - static get observedAttributes(): string[] { - return ['size'] - } - - private root: ShadowRoot - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - this.root.innerHTML = `` - } - - connectedCallback(): void { - this.applySize() - } - - attributeChangedCallback(): void { - if (this.isConnected) { - this.applySize() - } - } - - get size(): number | null { - const value = this.getAttribute('size') - if (value == null) return null - const parsed = parseFloat(value) - return Number.isNaN(parsed) ? null : parsed - } - set size(value: number | null) { - if (value == null) this.removeAttribute('size') - else this.setAttribute('size', String(value)) - } - - private applySize(): void { - const value = this.size - if (value != null) { - this.style.setProperty('--gc-title-size', `${value}px`) - } else { - this.style.removeProperty('--gc-title-size') - } - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: Title - } -} diff --git a/game-components/src/TitleScreen.ts b/game-components/src/TitleScreen.ts deleted file mode 100644 index 2323b7fd..00000000 --- a/game-components/src/TitleScreen.ts +++ /dev/null @@ -1,73 +0,0 @@ -const TAG_NAME = 'gc-title-screen' - -export class TitleScreen extends HTMLElement { - - static get observedAttributes(): string[] { - return ['title-text', 'subtitle'] - } - - constructor() { - super() - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'region') - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - get titleText(): string { - return this.getAttribute('title-text') ?? '' - } - set titleText(v: string) { - if (v) this.setAttribute('title-text', v) - else this.removeAttribute('title-text') - } - - get subtitle(): string { - return this.getAttribute('subtitle') ?? '' - } - set subtitle(v: string) { - if (v) this.setAttribute('subtitle', v) - else this.removeAttribute('subtitle') - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private render(): void { - const title = this.titleText - const subtitle = this.subtitle - - const titleMarkup = title - ? `${this.escape(title)}` - : '' - const subtitleMarkup = subtitle - ? `
    ${this.escape(subtitle)}
    ` - : '' - - this.innerHTML = ` -
    - Press Start - ${titleMarkup} -
    - ${subtitleMarkup} -
    - ` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: TitleScreen - } -} diff --git a/game-components/src/Toggle.ts b/game-components/src/Toggle.ts deleted file mode 100644 index eefbec96..00000000 --- a/game-components/src/Toggle.ts +++ /dev/null @@ -1,93 +0,0 @@ -const TAG_NAME = 'gc-toggle' - -export interface ToggleEventMap { - change: CustomEvent<{ on: boolean }> -} - -export class Toggle extends HTMLElement { - - static get observedAttributes(): string[] { - return ['on', 'disabled'] - } - - constructor() { - super() - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'switch') - if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', this.disabled ? '-1' : '0') - this.setAttribute('aria-checked', String(this.on)) - if (this.disabled) this.setAttribute('aria-disabled', 'true') - this.addEventListener('click', this.handleActivate) - this.addEventListener('keydown', this.handleKey) - this.render() - } - - disconnectedCallback(): void { - this.removeEventListener('click', this.handleActivate) - this.removeEventListener('keydown', this.handleKey) - } - - attributeChangedCallback(name: string): void { - if (!this.isConnected) return - if (name === 'on') this.setAttribute('aria-checked', String(this.on)) - if (name === 'disabled') { - if (this.disabled) { - this.setAttribute('aria-disabled', 'true') - this.setAttribute('tabindex', '-1') - } else { - this.removeAttribute('aria-disabled') - this.setAttribute('tabindex', '0') - } - } - this.render() - } - - get on(): boolean { - return this.hasAttribute('on') - } - set on(value: boolean) { - if (value) this.setAttribute('on', '') - else this.removeAttribute('on') - } - - get disabled(): boolean { - return this.hasAttribute('disabled') - } - set disabled(value: boolean) { - if (value) this.setAttribute('disabled', '') - else this.removeAttribute('disabled') - } - - private handleActivate = (event: Event): void => { - if (this.disabled) { - event.preventDefault() - event.stopPropagation() - return - } - this.toggle() - } - - private handleKey = (event: KeyboardEvent): void => { - if (this.disabled) return - if (event.key !== ' ' && event.key !== 'Enter') return - event.preventDefault() - this.toggle() - } - - private toggle(): void { - this.on = !this.on - this.dispatchEvent(new CustomEvent('change', { detail: { on: this.on }, bubbles: true, composed: true })) - } - - private render(): void { - this.innerHTML = `` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: Toggle - } -} diff --git a/game-components/src/ToggleRow.ts b/game-components/src/ToggleRow.ts deleted file mode 100644 index 0cbf81df..00000000 --- a/game-components/src/ToggleRow.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { SettingRowBase } from './SettingRowBase' - -const TAG_NAME = 'gc-toggle-row' - -export class ToggleRow extends SettingRowBase { - - static get observedAttributes(): string[] { - return [...SettingRowBase.observedAttributes, 'checked'] - } - - get checked(): boolean { - return this.hasAttribute('checked') - } - set checked(v: boolean) { - if (v) this.setAttribute('checked', '') - else this.removeAttribute('checked') - } - - protected renderControl(): string { - const checked = this.checked - return ` - - ` - } - - protected bindControl(): void { - const btn = this.querySelector('.gc-setting-row-toggle') as HTMLButtonElement | null - if (!btn) return - btn.addEventListener('click', () => { - const next = !this.checked - this.checked = next - btn.dataset.checked = String(next) - btn.setAttribute('aria-checked', String(next)) - this.emit('change', { value: next }) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ToggleRow - } -} diff --git a/game-components/src/TransitionWipe.ts b/game-components/src/TransitionWipe.ts deleted file mode 100644 index 32c0b21d..00000000 --- a/game-components/src/TransitionWipe.ts +++ /dev/null @@ -1,108 +0,0 @@ -const TAG_NAME = 'gc-transition-wipe' - -export type TransitionWipeDirection = 'fade' | 'left' | 'right' | 'up' | 'down' | 'iris' - -export interface TransitionWipeEventMap { - complete: CustomEvent -} - -export class TransitionWipe extends HTMLElement { - - static get observedAttributes(): string[] { - return ['show', 'direction', 'duration', 'wipe-color'] - } - - private root: ShadowRoot - private timer: number | null = null - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - this.root.innerHTML = `
    ` - } - - connectedCallback(): void { - this.applyStyle() - if (this.show) this.scheduleComplete() - } - - disconnectedCallback(): void { - this.clearTimer() - } - - attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void { - if (!this.isConnected) return - if (name === 'show') { - if (newValue !== null) this.scheduleComplete() - else this.clearTimer() - } - this.applyStyle() - } - - get show(): boolean { - return this.hasAttribute('show') - } - set show(value: boolean) { - if (value) this.setAttribute('show', '') - else this.removeAttribute('show') - } - - get direction(): TransitionWipeDirection { - const raw = (this.getAttribute('direction') ?? 'fade') as TransitionWipeDirection - const allowed: TransitionWipeDirection[] = ['fade', 'left', 'right', 'up', 'down', 'iris'] - return allowed.includes(raw) ? raw : 'fade' - } - set direction(value: TransitionWipeDirection) { - this.setAttribute('direction', value) - } - - get duration(): number { - const raw = this.getAttribute('duration') - if (raw == null) return 400 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed <= 0 ? 400 : parsed - } - set duration(value: number) { - this.setAttribute('duration', String(value)) - } - - get wipeColor(): string { - return this.getAttribute('wipe-color') ?? '#0a0604' - } - set wipeColor(value: string) { - this.setAttribute('wipe-color', value) - } - - private clearTimer(): void { - if (this.timer != null) { - window.clearTimeout(this.timer) - this.timer = null - } - } - - private scheduleComplete(): void { - this.clearTimer() - this.timer = window.setTimeout(() => { - this.timer = null - this.emit('complete') - }, this.duration) - } - - private emit(name: string, detail?: T): void { - this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) - } - - private applyStyle(): void { - const fill = this.root.querySelector('.gc-transition-wipe-fill') as HTMLElement | null - if (!fill) return - fill.dataset.direction = this.direction - fill.style.background = this.wipeColor - fill.style.setProperty('--gc-transition-wipe-duration', `${this.duration}ms`) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: TransitionWipe - } -} diff --git a/game-components/src/VSyncToggle.ts b/game-components/src/VSyncToggle.ts deleted file mode 100644 index 1b633191..00000000 --- a/game-components/src/VSyncToggle.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ToggleRow } from './ToggleRow' - -const TAG_NAME = 'gc-vsync-toggle' - -export class VSyncToggle extends ToggleRow { - - connectedCallback(): void { - if (!this.hasAttribute('row-label')) this.setAttribute('row-label', 'V-Sync') - super.connectedCallback() - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: VSyncToggle - } -} diff --git a/game-components/src/VersionLabel.ts b/game-components/src/VersionLabel.ts deleted file mode 100644 index a36e169d..00000000 --- a/game-components/src/VersionLabel.ts +++ /dev/null @@ -1,82 +0,0 @@ -const TAG_NAME = 'gc-version-label' - -export class VersionLabel extends HTMLElement { - - static get observedAttributes(): string[] { - return ['version', 'build', 'branch'] - } - - private root: ShadowRoot - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - this.root.innerHTML = `` - } - - connectedCallback(): void { - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) { - this.render() - } - } - - get version(): string { - return this.getAttribute('version') || '' - } - set version(value: string) { - if (value) this.setAttribute('version', value) - else this.removeAttribute('version') - } - - get build(): string { - return this.getAttribute('build') || '' - } - set build(value: string) { - if (value) this.setAttribute('build', value) - else this.removeAttribute('build') - } - - get branch(): string { - return this.getAttribute('branch') || '' - } - set branch(value: string) { - if (value) this.setAttribute('branch', value) - else this.removeAttribute('branch') - } - - private render(): void { - const parts: string[] = [] - const version = this.version - const build = this.build - const branch = this.branch - if (version) { - parts.push(`v${this.escape(version)}`) - } - if (build) { - parts.push(`build ${this.escape(build)}`) - } - if (branch) { - parts.push(`${this.escape(branch)}`) - } - this.innerHTML = parts.join('·') - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: VersionLabel - } -} diff --git a/game-components/src/VictoryScreen.ts b/game-components/src/VictoryScreen.ts deleted file mode 100644 index 427e81b1..00000000 --- a/game-components/src/VictoryScreen.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ResultScreen, ResultScreenTitleColor } from './ResultScreen' - -const TAG_NAME = 'gc-victory-screen' - -export class VictoryScreen extends ResultScreen { - - protected defaultTitleText(): string { - return 'Victory!' - } - - protected defaultTitleColor(): ResultScreenTitleColor { - return 'gold' - } - - protected eyebrowLabel(): string { - return 'Triumph' - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: VictoryScreen - } -} diff --git a/game-components/src/VignetteOverlay.ts b/game-components/src/VignetteOverlay.ts deleted file mode 100644 index 20d58cba..00000000 --- a/game-components/src/VignetteOverlay.ts +++ /dev/null @@ -1,53 +0,0 @@ -const TAG_NAME = 'gc-vignette-overlay' - -export class VignetteOverlay extends HTMLElement { - - static get observedAttributes(): string[] { - return ['intensity', 'vignette-color'] - } - - private root: ShadowRoot - - constructor() { - super() - this.root = this.attachShadow({ mode: 'open' }) - } - - connectedCallback(): void { - if (!this.root.firstChild) { - this.root.innerHTML = `` - } - this.applyTokens() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.applyTokens() - } - - get intensity(): number { - const v = parseFloat(this.getAttribute('intensity') ?? '0.6') - return isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.6 - } - set intensity(v: number) { - this.setAttribute('intensity', String(v)) - } - - get vignetteColor(): string { - return this.getAttribute('vignette-color') ?? '#000000' - } - set vignetteColor(v: string) { - if (v) this.setAttribute('vignette-color', v) - else this.removeAttribute('vignette-color') - } - - private applyTokens(): void { - this.style.setProperty('--gc-vignette-intensity', String(this.intensity)) - this.style.setProperty('--gc-vignette-color', this.vignetteColor) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: VignetteOverlay - } -} diff --git a/game-components/src/VolumeSlider.ts b/game-components/src/VolumeSlider.ts deleted file mode 100644 index 5b3a906c..00000000 --- a/game-components/src/VolumeSlider.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { SettingRowBase } from './SettingRowBase' - -const TAG_NAME = 'gc-volume-slider' - -export class VolumeSlider extends SettingRowBase { - - static get observedAttributes(): string[] { - return [...SettingRowBase.observedAttributes, 'value', 'muted'] - } - - connectedCallback(): void { - if (!this.hasAttribute('row-label')) this.setAttribute('row-label', 'Volume') - super.connectedCallback() - } - - get value(): number { - const raw = this.getAttribute('value') - if (raw == null) return 0.8 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? 0.8 : Math.max(0, Math.min(1, parsed)) - } - set value(v: number) { - this.setAttribute('value', String(v)) - } - - get muted(): boolean { - return this.hasAttribute('muted') - } - set muted(v: boolean) { - if (v) this.setAttribute('muted', '') - else this.removeAttribute('muted') - } - - protected renderControl(): string { - const value = this.value - const muted = this.muted - const glyph = muted ? '🔇' : '🔊' - return ` -
    - - - ${Math.round(value * 100)}% -
    - ` - } - - protected bindControl(): void { - const input = this.querySelector('.gc-setting-row-slider-input') as HTMLInputElement | null - const display = this.querySelector('.gc-setting-row-slider-value') as HTMLElement | null - const muteBtn = this.querySelector('.gc-setting-row-mute') as HTMLButtonElement | null - if (input) { - input.addEventListener('input', () => { - const v = parseFloat(input.value) - if (display) display.textContent = `${Math.round(v * 100)}%` - this.setAttribute('value', String(v)) - this.emit('change', { value: v }) - }) - } - if (muteBtn) { - muteBtn.addEventListener('click', () => { - this.emit('toggle-mute') - }) - } - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: VolumeSlider - } -} diff --git a/game-components/src/WaypointMarker.ts b/game-components/src/WaypointMarker.ts deleted file mode 100644 index 6295a123..00000000 --- a/game-components/src/WaypointMarker.ts +++ /dev/null @@ -1,123 +0,0 @@ -const TAG_NAME = 'gc-waypoint-marker' - -export class WaypointMarker extends HTMLElement { - - static get observedAttributes(): string[] { - return ['x', 'y', 'label', 'distance', 'color', 'icon', 'size'] - } - - constructor() { - super() - } - - connectedCallback(): void { - this.render() - } - - attributeChangedCallback(): void { - if (this.isConnected) this.render() - } - - private numberAttr(name: string, fallback: number | null): number | null { - const raw = this.getAttribute(name) - if (raw == null) return fallback - const parsed = parseFloat(raw) - return Number.isFinite(parsed) ? parsed : fallback - } - - get x(): number | null { return this.numberAttr('x', null) } - set x(v: number | null) { - if (v == null) this.removeAttribute('x') - else this.setAttribute('x', String(v)) - } - - get y(): number | null { return this.numberAttr('y', null) } - set y(v: number | null) { - if (v == null) this.removeAttribute('y') - else this.setAttribute('y', String(v)) - } - - get label(): string { return this.getAttribute('label') ?? '' } - set label(v: string) { - if (v) this.setAttribute('label', v) - else this.removeAttribute('label') - } - - get distance(): number | null { return this.numberAttr('distance', null) } - set distance(v: number | null) { - if (v == null) this.removeAttribute('distance') - else this.setAttribute('distance', String(v)) - } - - get color(): string { return this.getAttribute('color') ?? '' } - set color(v: string) { - if (v) this.setAttribute('color', v) - else this.removeAttribute('color') - } - - get icon(): string { return this.getAttribute('icon') ?? '' } - set icon(v: string) { - if (v) this.setAttribute('icon', v) - else this.removeAttribute('icon') - } - - get size(): number | null { return this.numberAttr('size', null) } - set size(v: number | null) { - if (v == null) this.removeAttribute('size') - else this.setAttribute('size', String(v)) - } - - private escape(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - private formatDistance(d: number): string { - if (d >= 1000) return `${(d / 1000).toFixed(1)}km` - return `${Math.round(d)}m` - } - - private render(): void { - const x = this.x - const y = this.y - const size = this.size - const color = this.color - - if (x != null) this.style.left = `${x}px` - else this.style.removeProperty('left') - if (y != null) this.style.top = `${y}px` - else this.style.removeProperty('top') - if (size != null) this.style.setProperty('--gc-waypoint-marker-size', `${size}px`) - else this.style.removeProperty('--gc-waypoint-marker-size') - if (color) this.style.setProperty('--gc-waypoint-marker-color', color) - else this.style.removeProperty('--gc-waypoint-marker-color') - - const icon = this.icon || '✦' - const label = this.label - const distance = this.distance - const distanceMarkup = distance != null - ? `${this.formatDistance(distance)}` - : '' - const labelMarkup = label - ? `${this.escape(label)}` - : '' - - this.innerHTML = ` -
    ${this.escape(icon)}
    -
    - ${labelMarkup} - ${distanceMarkup} -
    - ` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: WaypointMarker - } -} diff --git a/game-components/src/index.ts b/game-components/src/index.ts deleted file mode 100644 index aa0f1cee..00000000 --- a/game-components/src/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -export { register } from './register' -export * from './Stack' -export * from './Grid' -export * from './Anchor' -export * from './Panel' -export * from './GildedFrame' -export * from './ArtboardBackdrop' -export * from './Title' -export * from './Subtitle' -export * from './Eyebrow' -export * from './Key' -export * from './LoreText' -export * from './ScrollText' -export * from './VersionLabel' -export * from './IconBadge' -export * from './RarityChip' -export * from './CurrencyChip' -export * from './CurrencyDisplay' -export * from './Portrait' -export * from './PlatformIcon' -export * from './GamepadButtonPrompt' -export * from './CompassRose' -export * from './RuneCorner' -export * from './PingDisplay' -export * from './PageIndicator' -export * from './MetalButton' -export * from './NavButton' -export * from './MenuItem' -export * from './ListRow' -export * from './CircularProgress' -export * from './CooldownBadge' -export * from './HitMarker' -export * from './DamageNumber' -export * from './NetworkStatusIcon' -export * from './ComboCounter' -export * from './ScoreDisplay' -export * from './ScreenFlash' -export * from './ShakeContainer' -export * from './TransitionWipe' -export * from './InteractPrompt' -export * from './ResourceBarBase' -export * from './HealthBar' -export * from './ManaBar' -export * from './StaminaBar' -export * from './AmmoCounter' -export * from './BossBar' -export * from './SettingRowBase' -export * from './FOVSlider' -export * from './DeadzoneSlider' -export * from './VolumeSlider' -export * from './MouseSensitivity' -export * from './ToggleRow' -export * from './FullscreenToggle' -export * from './InvertAxisToggle' -export * from './VSyncToggle' -export * from './SelectRow' -export * from './FPSCapSelect' -export * from './GraphicsPresetPicker' -export * from './ResetToDefaults' -export * from './BuffIcon' -export * from './BuffBar' -export * from './Crosshair' -export * from './Speedometer' -export * from './BrightnessCalibration' -export * from './ParticleEmitter' -export * from './SafeArea' -export * from './AspectRatioBox' -export * from './TabBar' -export * from './MainMenu' -export * from './GcList' -export * from './SettingsCategoryList' -export * from './PauseMenu' -export * from './ComboBox' -export * from './DialogueBox' -export * from './ReportPlayerDialog' -export * from './InviteToast' -export * from './LegalScreen' -export * from './CharacterCreate' -export * from './CharacterSelect' -export * from './PlayerCard' -export * from './PlayerFrame' -export * from './TitleScreen' -export * from './LoadingScreen' -export * from './PauseScreen' -export * from './ResultScreen' -export * from './GameOverScreen' -export * from './VictoryScreen' -export * from './StatsScreen' -export * from './MatchmakingScreen' -export * from './Toggle' -export * from './Check' -export * from './KeyBinder' -export * from './VignetteOverlay' -export * from './BlurOverlay' -export * from './LetterboxBars' -export * from './Divider' -export * from './ConfirmDialog' -export * from './LoadingOverlay' -export * from './DebugOverlay' -export * from './ControlsRebindList' -export * from './ItemSlot' -export * from './ItemTooltip' -export * from './ItemCompare' -export * from './Hotbar' -export * from './InventoryGrid' -export * from './EquipmentDoll' -export * from './AbilityCard' -export * from './SkillBar' -export * from './CompassBar' -export * from './Minimap' -export * from './ObjectiveMarker' -export * from './WaypointMarker' -export * from './ChatWindow' -export * from './FriendsList' -export * from './MuteList' -export * from './KillFeed' -export * from './LevelHeader' -export * from './LevelSelect' -export * from './SkillTree' -export * from './Codex' -export * from './Journal' -export * from './QuestTracker' -export * from './AchievementList' -export * from './BattlePass' -export * from './CraftingPanel' -export * from './ShopPanel' -export * from './LootList' -export * from './LootPopup' -export * from './GuildPanel' -export * from './PartyPanel' -export * from './Lobby' -export * from './PerkPicker' -export * from './RadialWheel' -export * from './SaveSlotList' -export * from './ControllerLayoutPreview' -export * from './CreditsList' -export * from './CreditsScroll' -export * from './PressAnyKey' -export * from './StatRow' -export * from './PanelHeader' diff --git a/game-components/src/register.ts b/game-components/src/register.ts deleted file mode 100644 index 1a099f67..00000000 --- a/game-components/src/register.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { AbilityCard } from './AbilityCard' -import { AchievementList } from './AchievementList' -import { AmmoCounter } from './AmmoCounter' -import { Anchor } from './Anchor' -import { ArtboardBackdrop } from './ArtboardBackdrop' -import { AspectRatioBox } from './AspectRatioBox' -import { BattlePass } from './BattlePass' -import { BlurOverlay } from './BlurOverlay' -import { BossBar } from './BossBar' -import { BrightnessCalibration } from './BrightnessCalibration' -import { BuffBar } from './BuffBar' -import { BuffIcon } from './BuffIcon' -import { CharacterCreate } from './CharacterCreate' -import { CharacterSelect } from './CharacterSelect' -import { ChatWindow } from './ChatWindow' -import { Check } from './Check' -import { CircularProgress } from './CircularProgress' -import { CooldownBadge } from './CooldownBadge' -import { Codex } from './Codex' -import { ComboBox } from './ComboBox' -import { ComboCounter } from './ComboCounter' -import { CompassBar } from './CompassBar' -import { CompassRose } from './CompassRose' -import { ConfirmDialog } from './ConfirmDialog' -import { ControllerLayoutPreview } from './ControllerLayoutPreview' -import { ControlsRebindList } from './ControlsRebindList' -import { CraftingPanel } from './CraftingPanel' -import { CreditsList } from './CreditsList' -import { CreditsScroll } from './CreditsScroll' -import { Crosshair } from './Crosshair' -import { CurrencyChip } from './CurrencyChip' -import { CurrencyDisplay } from './CurrencyDisplay' -import { DamageNumber } from './DamageNumber' -import { DeadzoneSlider } from './DeadzoneSlider' -import { DebugOverlay } from './DebugOverlay' -import { DialogueBox } from './DialogueBox' -import { Divider } from './Divider' -import { EquipmentDoll } from './EquipmentDoll' -import { Eyebrow } from './Eyebrow' -import { FOVSlider } from './FOVSlider' -import { FPSCapSelect } from './FPSCapSelect' -import { FriendsList } from './FriendsList' -import { FullscreenToggle } from './FullscreenToggle' -import { GameOverScreen } from './GameOverScreen' -import { GamepadButtonPrompt } from './GamepadButtonPrompt' -import { GcList } from './GcList' -import { GildedFrame } from './GildedFrame' -import { GraphicsPresetPicker } from './GraphicsPresetPicker' -import { Grid } from './Grid' -import { GuildPanel } from './GuildPanel' -import { HealthBar } from './HealthBar' -import { HitMarker } from './HitMarker' -import { Hotbar } from './Hotbar' -import { IconBadge } from './IconBadge' -import { InteractPrompt } from './InteractPrompt' -import { InventoryGrid } from './InventoryGrid' -import { InvertAxisToggle } from './InvertAxisToggle' -import { InviteToast } from './InviteToast' -import { ItemCompare } from './ItemCompare' -import { ItemSlot } from './ItemSlot' -import { ItemTooltip } from './ItemTooltip' -import { Journal } from './Journal' -import { KeyBinder } from './KeyBinder' -import { Key } from './Key' -import { KillFeed } from './KillFeed' -import { LegalScreen } from './LegalScreen' -import { LetterboxBars } from './LetterboxBars' -import { LevelHeader } from './LevelHeader' -import { LevelSelect } from './LevelSelect' -import { ListRow } from './ListRow' -import { LoadingOverlay } from './LoadingOverlay' -import { LoadingScreen } from './LoadingScreen' -import { Lobby } from './Lobby' -import { LootList } from './LootList' -import { LootPopup } from './LootPopup' -import { LoreText } from './LoreText' -import { MainMenu } from './MainMenu' -import { ManaBar } from './ManaBar' -import { MatchmakingScreen } from './MatchmakingScreen' -import { MenuItem } from './MenuItem' -import { MetalButton } from './MetalButton' -import { Minimap } from './Minimap' -import { MouseSensitivity } from './MouseSensitivity' -import { MuteList } from './MuteList' -import { NavButton } from './NavButton' -import { NetworkStatusIcon } from './NetworkStatusIcon' -import { ObjectiveMarker } from './ObjectiveMarker' -import { PageIndicator } from './PageIndicator' -import { PanelHeader } from './PanelHeader' -import { Panel } from './Panel' -import { ParticleEmitter } from './ParticleEmitter' -import { PartyPanel } from './PartyPanel' -import { PauseMenu } from './PauseMenu' -import { PauseScreen } from './PauseScreen' -import { PerkPicker } from './PerkPicker' -import { PingDisplay } from './PingDisplay' -import { PlatformIcon } from './PlatformIcon' -import { PlayerCard } from './PlayerCard' -import { PlayerFrame } from './PlayerFrame' -import { Portrait } from './Portrait' -import { PressAnyKey } from './PressAnyKey' -import { QuestTracker } from './QuestTracker' -import { RadialWheel } from './RadialWheel' -import { RarityChip } from './RarityChip' -import { ReportPlayerDialog } from './ReportPlayerDialog' -import { ResetToDefaults } from './ResetToDefaults' -import { ResultScreen } from './ResultScreen' -import { RuneCorner } from './RuneCorner' -import { SafeArea } from './SafeArea' -import { SaveSlotList } from './SaveSlotList' -import { ScoreDisplay } from './ScoreDisplay' -import { ScreenFlash } from './ScreenFlash' -import { ScrollText } from './ScrollText' -import { SelectRow } from './SelectRow' -import { SettingsCategoryList } from './SettingsCategoryList' -import { ShakeContainer } from './ShakeContainer' -import { ShopPanel } from './ShopPanel' -import { SkillBar } from './SkillBar' -import { SkillTree } from './SkillTree' -import { Speedometer } from './Speedometer' -import { Stack } from './Stack' -import { StaminaBar } from './StaminaBar' -import { StatRow } from './StatRow' -import { StatsScreen } from './StatsScreen' -import { Subtitle } from './Subtitle' -import { TabBar } from './TabBar' -import { TitleScreen } from './TitleScreen' -import { Title } from './Title' -import { ToggleRow } from './ToggleRow' -import { Toggle } from './Toggle' -import { TransitionWipe } from './TransitionWipe' -import { VSyncToggle } from './VSyncToggle' -import { VersionLabel } from './VersionLabel' -import { VictoryScreen } from './VictoryScreen' -import { VignetteOverlay } from './VignetteOverlay' -import { VolumeSlider } from './VolumeSlider' -import { WaypointMarker } from './WaypointMarker' - -export function register(): void { - customElements.define('gc-ability-card', AbilityCard) - customElements.define('gc-achievement-list', AchievementList) - customElements.define('gc-ammo-counter', AmmoCounter) - customElements.define('gc-anchor', Anchor) - customElements.define('gc-artboard-backdrop', ArtboardBackdrop) - customElements.define('gc-aspect-ratio-box', AspectRatioBox) - customElements.define('gc-battle-pass', BattlePass) - customElements.define('gc-blur-overlay', BlurOverlay) - customElements.define('gc-boss-bar', BossBar) - customElements.define('gc-brightness-calibration', BrightnessCalibration) - customElements.define('gc-buff-bar', BuffBar) - customElements.define('gc-buff-icon', BuffIcon) - customElements.define('gc-character-create', CharacterCreate) - customElements.define('gc-character-select', CharacterSelect) - customElements.define('gc-chat-window', ChatWindow) - customElements.define('gc-check', Check) - customElements.define('gc-circular-progress', CircularProgress) - customElements.define('gc-cooldown-badge', CooldownBadge) - customElements.define('gc-codex', Codex) - customElements.define('gc-combo-box', ComboBox) - customElements.define('gc-combo-counter', ComboCounter) - customElements.define('gc-compass-bar', CompassBar) - customElements.define('gc-compass-rose', CompassRose) - customElements.define('gc-confirm-dialog', ConfirmDialog) - customElements.define('gc-controller-layout-preview', ControllerLayoutPreview) - customElements.define('gc-controls-rebind-list', ControlsRebindList) - customElements.define('gc-crafting-panel', CraftingPanel) - customElements.define('gc-credits-list', CreditsList) - customElements.define('gc-credits-scroll', CreditsScroll) - customElements.define('gc-crosshair', Crosshair) - customElements.define('gc-currency-chip', CurrencyChip) - customElements.define('gc-currency-display', CurrencyDisplay) - customElements.define('gc-damage-number', DamageNumber) - customElements.define('gc-deadzone-slider', DeadzoneSlider) - customElements.define('gc-debug-overlay', DebugOverlay) - customElements.define('gc-dialogue-box', DialogueBox) - customElements.define('gc-divider', Divider) - customElements.define('gc-equipment-doll', EquipmentDoll) - customElements.define('gc-eyebrow', Eyebrow) - customElements.define('gc-fov-slider', FOVSlider) - customElements.define('gc-fps-cap-select', FPSCapSelect) - customElements.define('gc-friends-list', FriendsList) - customElements.define('gc-fullscreen-toggle', FullscreenToggle) - customElements.define('gc-game-over-screen', GameOverScreen) - customElements.define('gc-gamepad-button-prompt', GamepadButtonPrompt) - customElements.define('gc-list', GcList) - customElements.define('gc-gilded-frame', GildedFrame) - customElements.define('gc-graphics-preset-picker', GraphicsPresetPicker) - customElements.define('gc-grid', Grid) - customElements.define('gc-guild-panel', GuildPanel) - customElements.define('gc-health-bar', HealthBar) - customElements.define('gc-hit-marker', HitMarker) - customElements.define('gc-hotbar', Hotbar) - customElements.define('gc-icon-badge', IconBadge) - customElements.define('gc-interact-prompt', InteractPrompt) - customElements.define('gc-inventory-grid', InventoryGrid) - customElements.define('gc-invert-axis-toggle', InvertAxisToggle) - customElements.define('gc-invite-toast', InviteToast) - customElements.define('gc-item-compare', ItemCompare) - customElements.define('gc-item-slot', ItemSlot) - customElements.define('gc-item-tooltip', ItemTooltip) - customElements.define('gc-journal', Journal) - customElements.define('gc-key-binder', KeyBinder) - customElements.define('gc-key', Key) - customElements.define('gc-kill-feed', KillFeed) - customElements.define('gc-legal-screen', LegalScreen) - customElements.define('gc-letterbox-bars', LetterboxBars) - customElements.define('gc-level-header', LevelHeader) - customElements.define('gc-level-select', LevelSelect) - customElements.define('gc-list-row', ListRow) - customElements.define('gc-loading-overlay', LoadingOverlay) - customElements.define('gc-loading-screen', LoadingScreen) - customElements.define('gc-lobby', Lobby) - customElements.define('gc-loot-list', LootList) - customElements.define('gc-loot-popup', LootPopup) - customElements.define('gc-lore-text', LoreText) - customElements.define('gc-main-menu', MainMenu) - customElements.define('gc-mana-bar', ManaBar) - customElements.define('gc-matchmaking-screen', MatchmakingScreen) - customElements.define('gc-menu-item', MenuItem) - customElements.define('gc-metal-button', MetalButton) - customElements.define('gc-minimap', Minimap) - customElements.define('gc-mouse-sensitivity', MouseSensitivity) - customElements.define('gc-mute-list', MuteList) - customElements.define('gc-nav-button', NavButton) - customElements.define('gc-network-status-icon', NetworkStatusIcon) - customElements.define('gc-objective-marker', ObjectiveMarker) - customElements.define('gc-page-indicator', PageIndicator) - customElements.define('gc-panel-header', PanelHeader) - customElements.define('gc-panel', Panel) - customElements.define('gc-particle-emitter', ParticleEmitter) - customElements.define('gc-party-panel', PartyPanel) - customElements.define('gc-pause-menu', PauseMenu) - customElements.define('gc-pause-screen', PauseScreen) - customElements.define('gc-perk-picker', PerkPicker) - customElements.define('gc-ping-display', PingDisplay) - customElements.define('gc-platform-icon', PlatformIcon) - customElements.define('gc-player-card', PlayerCard) - customElements.define('gc-player-frame', PlayerFrame) - customElements.define('gc-portrait', Portrait) - customElements.define('gc-press-any-key', PressAnyKey) - customElements.define('gc-quest-tracker', QuestTracker) - customElements.define('gc-radial-wheel', RadialWheel) - customElements.define('gc-rarity-chip', RarityChip) - customElements.define('gc-report-player-dialog', ReportPlayerDialog) - customElements.define('gc-reset-to-defaults', ResetToDefaults) - customElements.define('gc-result-screen', ResultScreen) - customElements.define('gc-rune-corner', RuneCorner) - customElements.define('gc-safe-area', SafeArea) - customElements.define('gc-save-slot-list', SaveSlotList) - customElements.define('gc-score-display', ScoreDisplay) - customElements.define('gc-screen-flash', ScreenFlash) - customElements.define('gc-scroll-text', ScrollText) - customElements.define('gc-select-row', SelectRow) - customElements.define('gc-settings-category-list', SettingsCategoryList) - customElements.define('gc-shake-container', ShakeContainer) - customElements.define('gc-shop-panel', ShopPanel) - customElements.define('gc-skill-bar', SkillBar) - customElements.define('gc-skill-tree', SkillTree) - customElements.define('gc-speedometer', Speedometer) - customElements.define('gc-stack', Stack) - customElements.define('gc-stamina-bar', StaminaBar) - customElements.define('gc-stat-row', StatRow) - customElements.define('gc-stats-screen', StatsScreen) - customElements.define('gc-subtitle', Subtitle) - customElements.define('gc-tab-bar', TabBar) - customElements.define('gc-title-screen', TitleScreen) - customElements.define('gc-title', Title) - customElements.define('gc-toggle-row', ToggleRow) - customElements.define('gc-toggle', Toggle) - customElements.define('gc-transition-wipe', TransitionWipe) - customElements.define('gc-vsync-toggle', VSyncToggle) - customElements.define('gc-version-label', VersionLabel) - customElements.define('gc-victory-screen', VictoryScreen) - customElements.define('gc-vignette-overlay', VignetteOverlay) - customElements.define('gc-volume-slider', VolumeSlider) - customElements.define('gc-waypoint-marker', WaypointMarker) -} diff --git a/game-components/style/components/_ability-card.scss b/game-components/style/components/_ability-card.scss deleted file mode 100644 index f0a7dbed..00000000 --- a/game-components/style/components/_ability-card.scss +++ /dev/null @@ -1,118 +0,0 @@ -gc-ability-card { - --gc-ability-card-rarity: var(--fg-common); - --gc-ability-card-glow: rgba(156, 148, 137, 0.18); - display: block; - box-sizing: border-box; - width: 280px; - padding: 12px 14px 14px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.96), rgba(10, 6, 4, 0.96)); - border: 1px solid var(--fg-gold-deep); - color: var(--fg-parch); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 0 18px var(--gc-ability-card-glow), - 0 6px 16px rgba(0, 0, 0, 0.6); -} - -gc-ability-card[data-rarity="uncommon"] { - --gc-ability-card-rarity: var(--fg-uncommon); - --gc-ability-card-glow: rgba(95, 168, 74, 0.22); -} - -gc-ability-card[data-rarity="rare"] { - --gc-ability-card-rarity: var(--fg-rare); - --gc-ability-card-glow: rgba(74, 127, 207, 0.25); -} - -gc-ability-card[data-rarity="epic"] { - --gc-ability-card-rarity: var(--fg-epic); - --gc-ability-card-glow: rgba(164, 77, 208, 0.28); -} - -gc-ability-card[data-rarity="legendary"] { - --gc-ability-card-rarity: var(--fg-legendary); - --gc-ability-card-glow: rgba(232, 162, 58, 0.34); -} - -gc-ability-card .gc-ability-card-header { - display: flex; - align-items: center; - gap: 10px; - padding-bottom: 8px; - border-bottom: 1px solid rgba(201, 169, 97, 0.2); - margin-bottom: 8px; -} - -gc-ability-card .gc-ability-card-icon-placeholder { - width: 48px; - height: 48px; - background: linear-gradient(180deg, #2a1f14, #0a0604); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18); - flex-shrink: 0; -} - -gc-ability-card .gc-ability-card-header-text { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -gc-ability-card .gc-ability-card-name { - font-family: var(--fg-display); - font-weight: 700; - font-size: 16px; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--gc-ability-card-rarity); - text-shadow: 0 1px 2px #000; -} - -gc-ability-card .gc-ability-card-keybind { - flex-shrink: 0; - align-self: flex-start; -} - -gc-ability-card .gc-ability-card-description { - font-family: var(--fg-body); - font-style: italic; - font-size: 13px; - line-height: 1.5; - color: var(--fg-parch-3); - margin-bottom: 8px; -} - -gc-ability-card .gc-ability-card-meta { - display: flex; - flex-direction: column; - gap: 3px; - padding-top: 6px; - border-top: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-ability-card .gc-ability-card-meta-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} - -gc-ability-card .gc-ability-card-meta-label { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-ability-card .gc-ability-card-meta-value { - font-family: var(--fg-mono); - font-size: 12px; - color: var(--fg-parch); - letter-spacing: 0.04em; -} diff --git a/game-components/style/components/_achievement-list.scss b/game-components/style/components/_achievement-list.scss deleted file mode 100644 index 37cc5284..00000000 --- a/game-components/style/components/_achievement-list.scss +++ /dev/null @@ -1,120 +0,0 @@ -gc-achievement-list { - display: flex; - flex-direction: column; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-achievement-list .gc-achievement-list-row { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - border-top: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-achievement-list .gc-achievement-list-row:first-child { - border-top: 0; -} - -gc-achievement-list .gc-achievement-list-row.is-locked { - opacity: 0.55; -} - -gc-achievement-list .gc-achievement-list-row.is-secret .gc-achievement-list-name { - font-style: italic; - color: var(--fg-parch-dim); -} - -gc-achievement-list .gc-achievement-list-icon { - width: 40px; - height: 40px; - flex: none; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--fg-display); - font-size: 18px; - color: var(--fg-parch-3); - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18); -} - -gc-achievement-list .gc-achievement-list-row.is-unlocked .gc-achievement-list-icon { - color: var(--fg-gold-bright); - border-color: var(--fg-gold); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.25), - 0 0 12px rgba(232, 200, 120, 0.4); -} - -gc-achievement-list .gc-achievement-list-text { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -gc-achievement-list .gc-achievement-list-name { - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-achievement-list .gc-achievement-list-row.is-unlocked .gc-achievement-list-name { - color: var(--fg-gold-bright); -} - -gc-achievement-list .gc-achievement-list-description { - font-family: var(--fg-body); - font-size: 12px; - color: var(--fg-parch-3); -} - -gc-achievement-list .gc-achievement-list-progress { - display: flex; - align-items: center; - gap: 8px; - margin-top: 4px; -} - -gc-achievement-list .gc-achievement-list-progress-bar { - flex: 1 1 auto; - height: 4px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.7); - overflow: hidden; -} - -gc-achievement-list .gc-achievement-list-progress-fill { - height: 100%; - background: linear-gradient(180deg, #f0d27a, #c9a961 60%, #5a4422); - transition: width 0.6s ease-out; -} - -gc-achievement-list .gc-achievement-list-progress-count { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - color: var(--fg-gold-bright); -} - -gc-achievement-list .gc-achievement-list-points { - flex: none; - font-family: var(--fg-mono); - font-size: 13px; - letter-spacing: 0.1em; - color: var(--fg-gold-bright); - padding: 4px 8px; - border: 1px solid var(--fg-gold-deep); - background: rgba(0, 0, 0, 0.4); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_ammo-counter.scss b/game-components/style/components/_ammo-counter.scss deleted file mode 100644 index 92d02ece..00000000 --- a/game-components/style/components/_ammo-counter.scss +++ /dev/null @@ -1,73 +0,0 @@ -gc-ammo-counter { - display: inline-block; - box-sizing: border-box; - padding: 10px 14px; - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 4px 10px rgba(0, 0, 0, 0.6); - color: var(--fg-parch); - min-width: 140px; -} - -gc-ammo-counter .gc-ammo-counter-weapon { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-parch-dim); - margin-bottom: 4px; -} - -gc-ammo-counter .gc-ammo-counter-row { - display: flex; - align-items: baseline; - gap: 4px; - font-family: var(--fg-mono); - color: var(--fg-parch); -} - -gc-ammo-counter .gc-ammo-counter-mag { - font-size: 28px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-ammo-counter .gc-ammo-counter-sep { - font-size: 16px; - color: var(--fg-parch-3); -} - -gc-ammo-counter .gc-ammo-counter-max { - font-size: 16px; - color: var(--fg-parch-3); -} - -gc-ammo-counter .gc-ammo-counter-reserve { - font-size: 14px; - color: var(--fg-parch); - margin-left: 8px; - letter-spacing: 0.08em; -} - -gc-ammo-counter[data-state="low"] .gc-ammo-counter-mag { - color: var(--fg-blood-bright); - text-shadow: 0 0 8px rgba(212, 74, 58, 0.6), 0 1px 2px #000; -} - -gc-ammo-counter[data-state="reloading"] .gc-ammo-counter-mag { - color: var(--fg-parch-dim); -} - -gc-ammo-counter .gc-ammo-counter-state { - margin-top: 4px; - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-gold-bright); -} diff --git a/game-components/style/components/_anchor.scss b/game-components/style/components/_anchor.scss deleted file mode 100644 index aff88fcd..00000000 --- a/game-components/style/components/_anchor.scss +++ /dev/null @@ -1,2 +0,0 @@ -.component.component-anchor { -} diff --git a/game-components/style/components/_artboard-backdrop.scss b/game-components/style/components/_artboard-backdrop.scss deleted file mode 100644 index 81133171..00000000 --- a/game-components/style/components/_artboard-backdrop.scss +++ /dev/null @@ -1,39 +0,0 @@ -gc-artboard-backdrop { - display: block; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-artboard-backdrop[kind="dark"] { - background: radial-gradient(120% 90% at 50% 0%, #1f180e 0%, #0a0604 80%); -} - -gc-artboard-backdrop[kind="scene"] { - background: - radial-gradient(60% 50% at 50% 30%, #4a3a22 0%, #1a1108 60%, #0a0604 100%), - linear-gradient(180deg, #1a1308 0%, #0a0604 100%); -} - -gc-artboard-backdrop[kind="parch"] { - background: linear-gradient(180deg, #1a1308 0%, #0a0604 100%); -} - -gc-artboard-backdrop[padding="none"] { - padding: 0; -} - -gc-artboard-backdrop[padding="sm"] { - padding: 12px; -} - -gc-artboard-backdrop[padding="md"] { - padding: 20px; -} - -gc-artboard-backdrop[padding="lg"] { - padding: 32px; -} - -gc-artboard-backdrop[padding="xl"] { - padding: 48px; -} diff --git a/game-components/style/components/_aspect-ratio-box.scss b/game-components/style/components/_aspect-ratio-box.scss deleted file mode 100644 index 4af3ee46..00000000 --- a/game-components/style/components/_aspect-ratio-box.scss +++ /dev/null @@ -1,26 +0,0 @@ -gc-aspect-ratio-box { - --gc-aspect-ratio: 16 / 9; - --gc-aspect-ratio-fallback: 56.25%; - display: block; - position: relative; - width: 100%; - aspect-ratio: var(--gc-aspect-ratio); -} - -@supports not (aspect-ratio: 1 / 1) { - gc-aspect-ratio-box { - height: 0; - padding-bottom: var(--gc-aspect-ratio-fallback); - } - - gc-aspect-ratio-box .gc-aspect-ratio-box-content { - position: absolute; - inset: 0; - } -} - -gc-aspect-ratio-box .gc-aspect-ratio-box-content { - display: block; - width: 100%; - height: 100%; -} diff --git a/game-components/style/components/_battle-pass.scss b/game-components/style/components/_battle-pass.scss deleted file mode 100644 index 8a17f7cd..00000000 --- a/game-components/style/components/_battle-pass.scss +++ /dev/null @@ -1,217 +0,0 @@ -gc-battle-pass { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px 20px; - box-sizing: border-box; - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); - color: var(--fg-parch); -} - -gc-battle-pass .gc-battle-pass-header { - display: flex; - align-items: baseline; - gap: 12px; -} - -gc-battle-pass .gc-battle-pass-end { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.16em; - color: var(--fg-parch-3); -} - -gc-battle-pass .gc-battle-pass-premium-badge { - margin-left: auto; - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - padding: 4px 10px; - background: rgba(0, 0, 0, 0.4); - border: 1px solid var(--fg-gold); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - 0 0 8px rgba(232, 200, 120, 0.4); -} - -gc-battle-pass .gc-battle-pass-progress { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-battle-pass .gc-battle-pass-level { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); -} - -gc-battle-pass .gc-battle-pass-bar { - position: relative; - height: 8px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.7); - overflow: hidden; -} - -gc-battle-pass .gc-battle-pass-bar-fill { - height: 100%; - background: linear-gradient(180deg, #f0d27a, #c9a961 60%, #5a4422); - box-shadow: 0 0 8px rgba(232, 200, 120, 0.4); - transition: width 0.6s ease-out; -} - -gc-battle-pass .gc-battle-pass-xp { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - color: var(--fg-parch); - align-self: flex-end; -} - -gc-battle-pass .gc-battle-pass-track { - display: flex; - gap: 8px; -} - -gc-battle-pass .gc-battle-pass-track-labels { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: flex-end; - flex: none; - padding: 4px 8px 4px 0; -} - -gc-battle-pass .gc-battle-pass-track-label { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); -} - -gc-battle-pass .gc-battle-pass-tiers { - flex: 1 1 auto; - display: flex; - gap: 6px; - overflow-x: auto; - padding-bottom: 4px; -} - -gc-battle-pass .gc-battle-pass-tier { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 4px; - flex: none; - width: 80px; -} - -gc-battle-pass .gc-battle-pass-tier-level { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.16em; - color: var(--fg-gold-bright); - text-align: center; - text-shadow: 0 1px 2px #000; -} - -gc-battle-pass .gc-battle-pass-tier.is-current .gc-battle-pass-tier-level { - color: #fff; - text-shadow: 0 0 8px rgba(232, 200, 120, 0.8); -} - -gc-battle-pass .gc-battle-pass-cell { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 2px; - height: 70px; - padding: 4px; - box-sizing: border-box; - font-family: var(--fg-display); - font-size: 18px; - color: var(--fg-parch); - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.6); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-battle-pass .gc-battle-pass-cell.is-empty { - visibility: hidden; -} - -gc-battle-pass .gc-battle-pass-cell.is-claimable { - color: var(--fg-gold-bright); - border-color: var(--fg-gold); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.25), - 0 0 12px rgba(232, 200, 120, 0.4); -} - -gc-battle-pass .gc-battle-pass-cell.is-claimable:hover { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-battle-pass .gc-battle-pass-cell.is-claimed { - color: var(--fg-stamina-bright); - cursor: default; -} - -gc-battle-pass .gc-battle-pass-cell.is-locked { - opacity: 0.55; - cursor: not-allowed; - color: var(--fg-parch-3); -} - -gc-battle-pass .gc-battle-pass-cell.is-gated { - opacity: 0.45; - cursor: not-allowed; - color: var(--fg-parch-dim); -} - -gc-battle-pass .gc-battle-pass-cell:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-battle-pass .gc-battle-pass-cell-icon { - font-size: 18px; - line-height: 1; -} - -gc-battle-pass .gc-battle-pass-cell-label { - font-family: var(--fg-mono); - font-size: 9px; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--fg-parch-3); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; - text-align: center; -} diff --git a/game-components/style/components/_blur-overlay.scss b/game-components/style/components/_blur-overlay.scss deleted file mode 100644 index faeb14b9..00000000 --- a/game-components/style/components/_blur-overlay.scss +++ /dev/null @@ -1,9 +0,0 @@ -gc-blur-overlay { - position: absolute; - inset: 0; - pointer-events: none; - background: var(--gc-blur-background, rgba(0, 0, 0, 0.45)); - backdrop-filter: blur(var(--gc-blur-amount, 8px)); - -webkit-backdrop-filter: blur(var(--gc-blur-amount, 8px)); - z-index: 1030; -} diff --git a/game-components/style/components/_boss-bar.scss b/game-components/style/components/_boss-bar.scss deleted file mode 100644 index 49594e17..00000000 --- a/game-components/style/components/_boss-bar.scss +++ /dev/null @@ -1,84 +0,0 @@ -gc-boss-bar { - display: block; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-boss-bar .gc-boss-bar-header { - display: flex; - align-items: baseline; - justify-content: center; - gap: 14px; - margin-bottom: 6px; - font-family: var(--fg-display); - text-transform: uppercase; -} - -gc-boss-bar .gc-boss-bar-name { - font-size: 18px; - letter-spacing: 0.18em; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000, 0 0 18px rgba(232, 200, 120, 0.35); -} - -gc-boss-bar .gc-boss-bar-epithet { - font-family: var(--fg-body); - font-style: italic; - text-transform: none; - color: var(--fg-parch-3); - font-size: 13px; -} - -gc-boss-bar .gc-boss-bar-phase { - font-size: 10px; - letter-spacing: 0.32em; - color: var(--fg-parch-dim); -} - -gc-boss-bar .gc-boss-bar-track { - position: relative; - height: 26px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 3px rgba(0, 0, 0, 0.8), - 0 4px 10px rgba(0, 0, 0, 0.6); - overflow: hidden; -} - -gc-boss-bar .gc-boss-bar-fill { - position: absolute; - top: 0; - left: 0; - height: 100%; - background: linear-gradient(180deg, #e0584a, #a8302a 60%, #5a1410); - transition: width 0.4s ease-out; -} - -gc-boss-bar .gc-boss-bar-scanlines { - position: absolute; - inset: 0; - pointer-events: none; - background: repeating-linear-gradient(90deg, transparent 0 9px, rgba(0, 0, 0, 0.18) 9px 10px); -} - -gc-boss-bar .gc-boss-bar-tick { - position: absolute; - top: -3px; - bottom: -3px; - width: 2px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - box-shadow: 0 0 4px rgba(232, 200, 120, 0.6); - pointer-events: none; -} - -gc-boss-bar .gc-boss-bar-numeric { - margin-top: 4px; - text-align: center; - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.08em; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_brightness-calibration.scss b/game-components/style/components/_brightness-calibration.scss deleted file mode 100644 index 29379bed..00000000 --- a/game-components/style/components/_brightness-calibration.scss +++ /dev/null @@ -1,106 +0,0 @@ -gc-brightness-calibration { - --gc-brightness-gamma: brightness(1); - display: block; - color: var(--fg-parch); -} - -gc-brightness-calibration .gc-brightness-calibration-preview { - display: flex; - height: 80px; - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.6); - filter: var(--gc-brightness-gamma); -} - -gc-brightness-calibration .gc-brightness-calibration-band { - flex: 1; - display: flex; - align-items: flex-end; - justify-content: center; - padding: 6px; - color: rgba(255, 255, 255, 0.6); -} - -gc-brightness-calibration .gc-brightness-calibration-dark { - background: linear-gradient(180deg, #050302, #1a1208); -} - -gc-brightness-calibration .gc-brightness-calibration-mid { - background: linear-gradient(180deg, #2a1f14, #4a3422); -} - -gc-brightness-calibration .gc-brightness-calibration-bright { - background: linear-gradient(180deg, #c5a060, #f0d27a); - color: rgba(0, 0, 0, 0.65); -} - -gc-brightness-calibration .gc-brightness-calibration-label { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -gc-brightness-calibration .gc-brightness-calibration-row { - display: flex; - align-items: center; - gap: 12px; - margin-top: 12px; -} - -gc-brightness-calibration .gc-brightness-calibration-row-label { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch); - min-width: 100px; -} - -gc-brightness-calibration .gc-brightness-calibration-input { - appearance: none; - -webkit-appearance: none; - flex: 1; - height: 8px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8); - cursor: pointer; -} - -gc-brightness-calibration .gc-brightness-calibration-input::-webkit-slider-thumb { - appearance: none; - -webkit-appearance: none; - width: 14px; - height: 14px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - border: 1px solid var(--fg-gold-deep); - box-shadow: 0 0 0 1px #000; -} - -gc-brightness-calibration .gc-brightness-calibration-input::-moz-range-thumb { - width: 14px; - height: 14px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - border: 1px solid var(--fg-gold-deep); - box-shadow: 0 0 0 1px #000; -} - -gc-brightness-calibration .gc-brightness-calibration-input:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-brightness-calibration .gc-brightness-calibration-input:focus:not(:focus-visible) { - outline: none; -} - -gc-brightness-calibration .gc-brightness-calibration-row-value { - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-gold-bright); - min-width: 48px; - text-align: right; -} diff --git a/game-components/style/components/_buff.scss b/game-components/style/components/_buff.scss deleted file mode 100644 index 23f8424a..00000000 --- a/game-components/style/components/_buff.scss +++ /dev/null @@ -1,80 +0,0 @@ -gc-buff-icon { - --gc-buff-icon-size: 36px; - position: relative; - display: inline-block; - box-sizing: border-box; - width: var(--gc-buff-icon-size); - height: var(--gc-buff-icon-size); - border: 1px solid var(--fg-stamina); - background: linear-gradient(135deg, #2a3a1a, #0a1006); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.1), - 0 2px 4px rgba(0, 0, 0, 0.5); -} - -gc-buff-icon[data-kind="debuff"] { - border-color: var(--fg-blood); - background: linear-gradient(135deg, #3a1a14, #100604); -} - -gc-buff-icon .gc-buff-icon-glyph { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - color: var(--fg-stamina-bright); - font-family: var(--fg-display); - font-size: calc(var(--gc-buff-icon-size) * 0.5); - line-height: 1; -} - -gc-buff-icon[data-kind="debuff"] .gc-buff-icon-glyph { - color: var(--fg-blood-bright); -} - -gc-buff-icon .gc-buff-icon-time { - position: absolute; - bottom: 1px; - right: 2px; - font-family: var(--fg-mono); - font-size: 10px; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000, 0 0 3px #000; - letter-spacing: 0.04em; -} - -gc-buff-bar { - --gc-buff-bar-gap: 6px; - display: inline-block; -} - -gc-buff-bar .gc-buff-bar-row { - display: inline-flex; - align-items: center; - gap: var(--gc-buff-bar-gap); -} - -gc-buff-bar .gc-buff-bar-cell { - position: relative; - display: inline-block; -} - -gc-buff-bar .gc-buff-bar-cooldown { - position: absolute; - inset: 0; - pointer-events: none; - background: conic-gradient(from -90deg, rgba(0, 0, 0, 0.55) var(--cd, 0deg), transparent 0); -} - -gc-buff-bar .gc-buff-bar-stack { - position: absolute; - bottom: 1px; - left: 2px; - font-family: var(--fg-mono); - font-size: 10px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; - letter-spacing: 0.04em; -} diff --git a/game-components/style/components/_character-create.scss b/game-components/style/components/_character-create.scss deleted file mode 100644 index 52cc09cb..00000000 --- a/game-components/style/components/_character-create.scss +++ /dev/null @@ -1,161 +0,0 @@ -gc-character-create { - display: block; - box-sizing: border-box; - width: 100%; - color: var(--fg-parch); -} - -gc-character-create .gc-character-create-root { - display: flex; - flex-direction: column; - gap: 18px; - padding: 20px 22px; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-character-create .gc-character-create-header { - display: flex; - flex-direction: column; - gap: 8px; -} - -gc-character-create .gc-character-create-eyebrow { - display: block; - color: var(--fg-parch-dim); -} - -gc-character-create .gc-character-create-name { - box-sizing: border-box; - width: 100%; - padding: 10px 12px; - font-family: var(--fg-display); - font-size: 18px; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--fg-gold-bright); - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8); - outline: none; -} - -gc-character-create .gc-character-create-name::placeholder { - color: var(--fg-parch-dim); - text-transform: none; - letter-spacing: 0; - font-style: italic; -} - -gc-character-create .gc-character-create-name:focus { - border-color: var(--fg-gold-bright); -} - -gc-character-create .gc-character-create-name:focus-visible, -gc-character-create .gc-character-create-control:focus-visible, -gc-character-create .gc-character-create-range:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-character-create .gc-character-create-name:focus:not(:focus-visible), -gc-character-create .gc-character-create-control:focus:not(:focus-visible), -gc-character-create .gc-character-create-range:focus:not(:focus-visible) { - outline: none; -} - -gc-character-create .gc-character-create-fields { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 12px 16px; -} - -gc-character-create .gc-character-create-field { - display: flex; - flex-direction: column; - gap: 6px; -} - -gc-character-create .gc-character-create-field-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-character-create .gc-character-create-control { - box-sizing: border-box; - width: 100%; - padding: 8px 10px; - font-family: var(--fg-mono); - font-size: 13px; - color: var(--fg-parch); - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8); - outline: none; -} - -gc-character-create select.gc-character-create-control { - appearance: none; - -webkit-appearance: none; - cursor: pointer; - background-image: linear-gradient(180deg, #2a1f14, #0a0604); - color: var(--fg-gold-bright); -} - -gc-character-create .gc-character-create-control:focus { - border-color: var(--fg-gold-bright); -} - -gc-character-create .gc-character-create-range-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; -} - -gc-character-create .gc-character-create-range-value { - font-family: var(--fg-mono); - font-size: 13px; - color: var(--fg-gold-bright); -} - -gc-character-create .gc-character-create-range { - appearance: none; - -webkit-appearance: none; - width: 100%; - height: 8px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8); - outline: none; -} - -gc-character-create .gc-character-create-range::-webkit-slider-thumb { - appearance: none; - width: 14px; - height: 14px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - box-shadow: 0 0 0 1px #000, 0 0 6px rgba(232, 200, 120, 0.5); - transform: rotate(45deg); - cursor: pointer; -} - -gc-character-create .gc-character-create-range::-moz-range-thumb { - width: 14px; - height: 14px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - box-shadow: 0 0 0 1px #000, 0 0 6px rgba(232, 200, 120, 0.5); - border: none; - cursor: pointer; -} - -gc-character-create .gc-character-create-footer { - display: flex; - justify-content: flex-end; -} diff --git a/game-components/style/components/_character-select.scss b/game-components/style/components/_character-select.scss deleted file mode 100644 index 2f516908..00000000 --- a/game-components/style/components/_character-select.scss +++ /dev/null @@ -1,162 +0,0 @@ -gc-character-select { - display: block; - box-sizing: border-box; - width: 100%; - color: var(--fg-parch); -} - -gc-character-select .gc-character-select-root { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 16px; -} - -@media (max-width: 720px) { - gc-character-select .gc-character-select-root { - grid-template-columns: 1fr; - } -} - -gc-character-select .gc-character-select-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 10px; -} - -gc-character-select .gc-character-select-tile { - position: relative; - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); - cursor: pointer; - user-select: none; - transition: background 0.15s ease, border-color 0.15s ease; -} - -gc-character-select .gc-character-select-tile:hover:not(.is-locked) { - background: linear-gradient(180deg, #2a1f14 0%, #1a1308 100%); -} - -gc-character-select .gc-character-select-tile.is-selected { - border-color: var(--fg-gold-bright); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 12px rgba(232, 200, 120, 0.25), - 0 0 0 1px var(--fg-gold-bright); -} - -gc-character-select .gc-character-select-tile.is-locked { - opacity: 0.55; - cursor: not-allowed; -} - -gc-character-select .gc-character-select-tile:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-character-select .gc-character-select-tile:focus:not(:focus-visible) { - outline: none; -} - -gc-character-select .gc-character-select-tile-body { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -gc-character-select .gc-character-select-tile-name { - font-family: var(--fg-display); - font-weight: 600; - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-gold-bright); -} - -gc-character-select .gc-character-select-tile-role { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-character-select .gc-character-select-lock { - margin-left: auto; - color: var(--fg-parch-dim); - font-size: 14px; -} - -gc-character-select .gc-character-select-detail { - padding: 16px 18px; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-character-select .gc-character-select-detail-empty { - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-dim); -} - -gc-character-select .gc-character-select-detail-role { - display: block; - color: var(--fg-parch-dim); - margin-bottom: 4px; -} - -gc-character-select .gc-character-select-detail-name { - display: block; - --gc-title-size: 22px; - color: var(--fg-gold-bright); - margin-bottom: 8px; -} - -gc-character-select .gc-character-select-detail-description { - font-family: var(--fg-body); - font-style: italic; - font-size: 13px; - line-height: 1.5; - color: var(--fg-parch-3); - margin-bottom: 12px; -} - -gc-character-select .gc-character-select-detail-stats { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-character-select .gc-character-select-detail-stat { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 4px 0; - border-bottom: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-character-select .gc-character-select-detail-stat-label { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-character-select .gc-character-select-detail-stat-value { - font-family: var(--fg-mono); - font-size: 13px; - color: var(--fg-parch); -} diff --git a/game-components/style/components/_chat-window.scss b/game-components/style/components/_chat-window.scss deleted file mode 100644 index c940f640..00000000 --- a/game-components/style/components/_chat-window.scss +++ /dev/null @@ -1,164 +0,0 @@ -gc-chat-window { - display: flex; - flex-direction: column; - box-sizing: border-box; - width: var(--gc-chat-window-width, 360px); - height: var(--gc-chat-window-height, 280px); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); - color: var(--fg-parch); - font-family: var(--fg-body); -} - -gc-chat-window .gc-chat-window-tabs { - display: flex; - gap: 0; - border-bottom: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-chat-window .gc-chat-window-tab { - padding: 8px 14px; - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); - background: transparent; - border-bottom: 2px solid transparent; - cursor: pointer; - user-select: none; - transition: color 0.15s ease, background 0.15s ease; -} - -gc-chat-window .gc-chat-window-tab:hover { - color: var(--fg-parch); - background: rgba(201, 169, 97, 0.06); -} - -gc-chat-window .gc-chat-window-tab.is-active { - color: var(--gc-chat-window-tab-color, var(--fg-gold-bright)); - background: rgba(201, 169, 97, 0.12); - border-bottom-color: var(--gc-chat-window-tab-color, var(--fg-gold-bright)); -} - -gc-chat-window .gc-chat-window-tab:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-chat-window .gc-chat-window-tab:focus:not(:focus-visible) { - outline: none; -} - -gc-chat-window .gc-chat-window-messages { - flex: 1 1 auto; - box-sizing: border-box; - padding: 8px 12px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-chat-window .gc-chat-window-message { - font-family: var(--fg-body); - font-size: 13px; - line-height: 1.4; - color: var(--fg-parch); -} - -gc-chat-window .gc-chat-window-message-sender { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--gc-chat-window-message-color, var(--fg-gold-bright)); - margin-right: 6px; -} - -gc-chat-window .gc-chat-window-message-body { - color: var(--fg-parch); -} - -gc-chat-window .gc-chat-window-message.is-system { - font-style: italic; - color: var(--gc-chat-window-message-color, var(--fg-parch-3)); - font-size: 12px; - text-align: center; - padding: 2px 0; -} - -gc-chat-window .gc-chat-window-message.is-system .gc-chat-window-message-body { - color: inherit; -} - -gc-chat-window .gc-chat-window-compose { - display: flex; - gap: 6px; - padding: 8px 10px; - border-top: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-chat-window .gc-chat-window-input { - flex: 1 1 auto; - box-sizing: border-box; - padding: 6px 10px; - font-family: var(--fg-body); - font-size: 13px; - color: var(--fg-parch); - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.7); - outline: none; -} - -gc-chat-window .gc-chat-window-input:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -1px; -} - -gc-chat-window .gc-chat-window-input::placeholder { - color: var(--fg-parch-dim); - font-style: italic; -} - -gc-chat-window .gc-chat-window-send { - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - font-size: 11px; - padding: 6px 14px; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-chat-window .gc-chat-window-send:hover { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-chat-window .gc-chat-window-send:active { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-chat-window .gc-chat-window-send:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} diff --git a/game-components/style/components/_check.scss b/game-components/style/components/_check.scss deleted file mode 100644 index 050dd70c..00000000 --- a/game-components/style/components/_check.scss +++ /dev/null @@ -1,47 +0,0 @@ -gc-check { - display: inline-block; - cursor: pointer; - user-select: none; - line-height: 0; -} - -gc-check[disabled] { - cursor: not-allowed; - opacity: 0.45; -} - -gc-check .gc-check-box { - position: relative; - display: inline-block; - width: 16px; - height: 16px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 4px rgba(0, 0, 0, 0.6); - box-sizing: border-box; -} - -gc-check .gc-check-mark { - position: absolute; - inset: 0; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - clip-path: polygon(15% 50%, 40% 75%, 90% 20%, 90% 35%, 40% 90%, 15% 65%); - opacity: 0; - transition: opacity 0.15s ease; -} - -gc-check[on] .gc-check-mark { - opacity: 1; -} - -gc-check:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-check:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_circular-progress.scss b/game-components/style/components/_circular-progress.scss deleted file mode 100644 index 49ad929b..00000000 --- a/game-components/style/components/_circular-progress.scss +++ /dev/null @@ -1,47 +0,0 @@ -gc-circular-progress { - --gc-circular-progress-size: 64px; - --gc-circular-progress-color: var(--fg-gold-bright); - --gc-circular-progress-bg: var(--fg-gold-shadow); - display: inline-flex; - position: relative; - align-items: center; - justify-content: center; - width: var(--gc-circular-progress-size); - height: var(--gc-circular-progress-size); - line-height: 0; -} - -gc-circular-progress .gc-circular-progress-svg { - transform: rotate(-90deg); - overflow: visible; -} - -gc-circular-progress .gc-circular-progress-svg.is-reverse { - transform: rotate(-90deg) scaleX(-1); -} - -gc-circular-progress .gc-circular-progress-track { - stroke: var(--gc-circular-progress-bg); - opacity: 0.6; -} - -gc-circular-progress .gc-circular-progress-fill { - stroke: var(--gc-circular-progress-color); - stroke-linecap: square; - filter: drop-shadow(0 0 4px rgba(232, 200, 120, 0.4)); - transition: stroke-dashoffset 0.4s ease-out; -} - -gc-circular-progress .gc-circular-progress-text { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--fg-mono); - font-size: calc(var(--gc-circular-progress-size) * 0.22); - letter-spacing: 0.06em; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000; - pointer-events: none; -} diff --git a/game-components/style/components/_codex.scss b/game-components/style/components/_codex.scss deleted file mode 100644 index a4f881ba..00000000 --- a/game-components/style/components/_codex.scss +++ /dev/null @@ -1,137 +0,0 @@ -gc-codex { - display: grid; - grid-template-columns: minmax(200px, 1fr) 2fr; - gap: 0; - box-sizing: border-box; - color: var(--fg-parch); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); -} - -gc-codex .gc-codex-list { - display: flex; - flex-direction: column; - border-right: 1px solid rgba(201, 169, 97, 0.25); - overflow-y: auto; - max-height: 460px; -} - -gc-codex .gc-codex-row { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-top: 1px solid rgba(201, 169, 97, 0.15); - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-codex .gc-codex-row:first-child { - border-top: 0; -} - -gc-codex .gc-codex-row:hover { - background: rgba(201, 169, 97, 0.08); -} - -gc-codex .gc-codex-row.is-selected { - background: linear-gradient(90deg, rgba(201, 169, 97, 0.18), transparent); - box-shadow: inset 3px 0 0 var(--fg-gold); - color: var(--fg-gold-bright); -} - -gc-codex .gc-codex-row.is-undiscovered { - color: var(--fg-parch-dim); - font-style: italic; -} - -gc-codex .gc-codex-row-glyph { - font-family: var(--fg-display); - font-size: 14px; - width: 20px; - text-align: center; - color: var(--fg-gold-bright); -} - -gc-codex .gc-codex-row.is-undiscovered .gc-codex-row-glyph { - color: var(--fg-parch-dim); -} - -gc-codex .gc-codex-row-name { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -gc-codex .gc-codex-row:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-codex .gc-codex-row:focus:not(:focus-visible) { - outline: none; -} - -gc-codex .gc-codex-detail { - display: flex; - flex-direction: column; - gap: 8px; - padding: 16px 20px; - box-sizing: border-box; - overflow-y: auto; - max-height: 460px; -} - -gc-codex .gc-codex-detail-empty { - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-3); - font-size: 14px; -} - -gc-codex .gc-codex-detail-description { - font-family: var(--fg-body); - font-size: 14px; - line-height: 1.55; - color: var(--fg-parch); - margin: 0; -} - -gc-codex .gc-codex-detail-stats { - display: flex; - flex-direction: column; - gap: 4px; - padding-top: 8px; - border-top: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-codex .gc-codex-detail-stat { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 12px; -} - -gc-codex .gc-codex-detail-stat-label { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); -} - -gc-codex .gc-codex-detail-stat-value { - font-family: var(--fg-mono); - font-size: 12px; - color: var(--fg-gold-bright); -} diff --git a/game-components/style/components/_combo-box.scss b/game-components/style/components/_combo-box.scss deleted file mode 100644 index 4737da54..00000000 --- a/game-components/style/components/_combo-box.scss +++ /dev/null @@ -1,159 +0,0 @@ -gc-combo-box { - display: inline-block; - position: relative; - box-sizing: border-box; - color: var(--fg-parch); - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; - min-width: 180px; -} - -gc-combo-box .gc-combo-box-button { - display: inline-flex; - align-items: center; - gap: 8px; - width: 100%; - box-sizing: border-box; - padding: 8px 12px; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - cursor: pointer; - user-select: none; - text-align: left; - font: inherit; - letter-spacing: inherit; - text-transform: inherit; - transition: filter 0.12s ease; -} - -gc-combo-box .gc-combo-box-button:hover:not(.is-disabled) { - filter: brightness(1.15); -} - -gc-combo-box .gc-combo-box-button.is-placeholder { - color: var(--fg-parch-3); -} - -gc-combo-box .gc-combo-box-button.is-disabled { - opacity: 0.45; - cursor: not-allowed; -} - -gc-combo-box .gc-combo-box-button-label { - flex: 1 1 auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -gc-combo-box .gc-combo-box-button-caret { - font-family: var(--fg-mono); - color: var(--fg-gold); - font-size: 11px; - line-height: 1; -} - -gc-combo-box .gc-combo-box-button:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-combo-box .gc-combo-box-button:focus:not(:focus-visible) { - outline: none; -} - -gc-combo-box .gc-combo-box-popover { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; - z-index: 1080; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12), - 0 8px 24px rgba(0, 0, 0, 0.6); -} - -gc-combo-box .gc-combo-box-search { - padding: 8px; - border-bottom: 1px solid rgba(201, 169, 97, 0.2); -} - -gc-combo-box .gc-combo-box-input { - box-sizing: border-box; - width: 100%; - padding: 6px 10px; - background: rgba(0, 0, 0, 0.5); - color: var(--fg-parch); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.6); - font-family: var(--fg-mono); - font-size: 12px; - letter-spacing: 0.04em; - text-transform: none; - outline: none; -} - -gc-combo-box .gc-combo-box-input:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -1px; -} - -gc-combo-box .gc-combo-box-list { - max-height: 240px; - overflow-y: auto; -} - -gc-combo-box .gc-combo-box-option { - box-sizing: border-box; - padding: 8px 12px; - color: var(--fg-parch); - background: transparent; - border-top: 1px solid rgba(201, 169, 97, 0.12); - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-combo-box .gc-combo-box-option:first-child { - border-top: 0; -} - -gc-combo-box .gc-combo-box-option:hover { - color: var(--fg-gold-bright); - background: rgba(201, 169, 97, 0.1); -} - -gc-combo-box .gc-combo-box-option.is-selected { - color: var(--fg-gold-bright); - background: linear-gradient(90deg, rgba(201, 169, 97, 0.18), transparent); - box-shadow: inset 3px 0 0 var(--fg-gold); -} - -gc-combo-box .gc-combo-box-option:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-combo-box .gc-combo-box-option:focus:not(:focus-visible) { - outline: none; -} - -gc-combo-box .gc-combo-box-empty { - padding: 10px 12px; - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-3); - font-size: 12px; - letter-spacing: 0.04em; - text-transform: none; -} diff --git a/game-components/style/components/_combo-counter.scss b/game-components/style/components/_combo-counter.scss deleted file mode 100644 index d6fbc808..00000000 --- a/game-components/style/components/_combo-counter.scss +++ /dev/null @@ -1,46 +0,0 @@ -gc-combo-counter { - --gc-combo-counter-font-size: 36px; - display: none; - flex-direction: column; - align-items: flex-start; - gap: 4px; - line-height: 1; -} - -gc-combo-counter[data-visible] { - display: inline-flex; -} - -gc-combo-counter .gc-combo-counter-eyebrow { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-combo-counter .gc-combo-counter-value { - font-family: var(--fg-mono); - font-weight: 700; - font-size: var(--gc-combo-counter-font-size); - letter-spacing: 0.04em; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000, 0 0 12px rgba(232, 200, 120, 0.4); -} - -gc-combo-counter .gc-combo-counter-bar { - display: block; - width: 100%; - min-width: 80px; - height: 4px; - background: rgba(0, 0, 0, 0.7); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.8); -} - -gc-combo-counter .gc-combo-counter-bar-fill { - display: block; - height: 100%; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transition: width 0.15s ease; -} diff --git a/game-components/style/components/_compass-bar.scss b/game-components/style/components/_compass-bar.scss deleted file mode 100644 index 2fd5c47f..00000000 --- a/game-components/style/components/_compass-bar.scss +++ /dev/null @@ -1,86 +0,0 @@ -gc-compass-bar { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - box-sizing: border-box; - width: var(--gc-compass-bar-width, 320px); - color: var(--fg-parch); - font-family: var(--fg-display); -} - -gc-compass-bar .gc-compass-bar-track { - position: relative; - width: 100%; - height: var(--gc-compass-bar-height, 28px); - box-sizing: border-box; - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.4)), - linear-gradient(180deg, #1a1308, #0a0604); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.6); - overflow: hidden; -} - -gc-compass-bar .gc-compass-bar-cardinal { - position: absolute; - top: 50%; - transform: translate(-50%, -50%); - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.18em; - color: var(--fg-gold-bright); - text-transform: uppercase; - text-shadow: 0 1px 2px #000; - pointer-events: none; -} - -gc-compass-bar .gc-compass-bar-marker { - position: absolute; - top: 50%; - transform: translate(-50%, -50%); - display: inline-flex; - flex-direction: column; - align-items: center; - color: var(--gc-compass-bar-marker-color, var(--fg-parch)); - pointer-events: none; -} - -gc-compass-bar .gc-compass-bar-marker-icon { - font-size: 12px; - line-height: 1; - text-shadow: 0 1px 2px #000; -} - -gc-compass-bar .gc-compass-bar-marker-label { - font-family: var(--fg-mono); - font-size: 9px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-3); - text-shadow: 0 1px 2px #000; - margin-top: 2px; -} - -gc-compass-bar .gc-compass-bar-pointer { - position: absolute; - left: 50%; - bottom: -4px; - transform: translateX(-50%); - color: var(--fg-gold-bright); - font-size: 12px; - line-height: 1; - text-shadow: 0 1px 2px #000; - pointer-events: none; -} - -gc-compass-bar .gc-compass-bar-heading { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.18em; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_compass-rose.scss b/game-components/style/components/_compass-rose.scss deleted file mode 100644 index b0a27ff2..00000000 --- a/game-components/style/components/_compass-rose.scss +++ /dev/null @@ -1,53 +0,0 @@ -gc-compass-rose { - --gc-compass-size: 64px; - --gc-compass-rotation: 0deg; - display: inline-block; - width: var(--gc-compass-size); - height: var(--gc-compass-size); - line-height: 0; -} - -gc-compass-rose .gc-compass-rose-face { - width: 100%; - height: 100%; - display: block; -} - -gc-compass-rose .gc-compass-rose-ring { - fill: none; - stroke: var(--fg-gold-deep); - stroke-width: 2; -} - -gc-compass-rose .gc-compass-rose-bg { - fill: #1a130c; - stroke: var(--fg-gold-shadow); - stroke-width: 1; -} - -gc-compass-rose .gc-compass-rose-needle { - transform-origin: 50px 50px; - transform: rotate(var(--gc-compass-rotation)); - transition: transform 0.4s ease-out; -} - -gc-compass-rose .gc-compass-rose-needle-n { - fill: var(--fg-blood-bright); - stroke: #000; - stroke-width: 0.6; -} - -gc-compass-rose .gc-compass-rose-needle-s { - fill: var(--fg-parch); - stroke: #000; - stroke-width: 0.6; -} - -gc-compass-rose .gc-compass-rose-label { - font-family: var(--fg-display); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.18em; - fill: var(--fg-gold-bright); - text-transform: uppercase; -} diff --git a/game-components/style/components/_confirm-dialog.scss b/game-components/style/components/_confirm-dialog.scss deleted file mode 100644 index 46eb8487..00000000 --- a/game-components/style/components/_confirm-dialog.scss +++ /dev/null @@ -1,86 +0,0 @@ -gc-confirm-dialog { - display: none; - position: fixed; - inset: 0; - z-index: 1050; -} - -gc-confirm-dialog[open] { - display: block; -} - -gc-confirm-dialog .gc-confirm-dialog-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(2px); - z-index: 1040; -} - -gc-confirm-dialog .gc-confirm-dialog-panel { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1050; - box-sizing: border-box; - width: min(440px, calc(100vw - 32px)); - padding: 30px 24px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.96), rgba(10, 6, 4, 0.96)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px var(--fg-gold-shadow), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 20px 50px rgba(0, 0, 0, 0.8); - color: var(--fg-parch); - text-align: center; -} - -gc-confirm-dialog .gc-confirm-dialog-eyebrow { - display: block; - color: var(--fg-parch-dim); - margin-bottom: 6px; -} - -gc-confirm-dialog .gc-confirm-dialog-title { - display: block; - margin-bottom: 14px; -} - -gc-confirm-dialog .gc-confirm-dialog-divider { - display: flex; - align-items: center; - gap: 8px; - justify-content: center; - margin: 0 0 18px 0; -} - -gc-confirm-dialog .gc-confirm-dialog-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-confirm-dialog .gc-confirm-dialog-diamond { - display: inline-block; - width: 8px; - height: 8px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); -} - -gc-confirm-dialog .gc-confirm-dialog-message { - font-family: var(--fg-body); - font-size: 14px; - line-height: 1.5; - color: var(--fg-parch); - margin: 0 0 22px 0; -} - -gc-confirm-dialog .gc-confirm-dialog-actions { - display: flex; - gap: 10px; - justify-content: flex-end; - margin-top: 8px; -} diff --git a/game-components/style/components/_controller-layout-preview.scss b/game-components/style/components/_controller-layout-preview.scss deleted file mode 100644 index e07a8d87..00000000 --- a/game-components/style/components/_controller-layout-preview.scss +++ /dev/null @@ -1,61 +0,0 @@ -gc-controller-layout-preview { - position: relative; - display: inline-flex; - flex-direction: column; - align-items: center; - gap: 6px; - padding: 12px 16px; - box-sizing: border-box; - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 0 rgba(232, 200, 120, 0.18); - color: var(--fg-parch); -} - -gc-controller-layout-preview .gc-controller-layout-preview-body { - width: 240px; - height: 140px; -} - -gc-controller-layout-preview .gc-controller-layout-preview-buttons { - position: absolute; - top: 38px; - right: 36px; - width: 56px; - height: 56px; -} - -gc-controller-layout-preview .gc-controller-layout-preview-btn { - position: absolute; - width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--gc-controller-layout-preview-btn-color, var(--fg-gold-deep)); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 0 6px var(--gc-controller-layout-preview-btn-color, rgba(232, 200, 120, 0.35)); - color: var(--gc-controller-layout-preview-btn-color, var(--fg-gold-bright)); - border-radius: 50%; - font-family: var(--fg-display); - font-size: 11px; - font-weight: 600; - text-shadow: 0 1px 2px #000; -} - -gc-controller-layout-preview .gc-controller-layout-preview-btn-top { top: 0; left: 50%; transform: translateX(-50%); } -gc-controller-layout-preview .gc-controller-layout-preview-btn-right { right: 0; top: 50%; transform: translateY(-50%); } -gc-controller-layout-preview .gc-controller-layout-preview-btn-bottom { bottom: 0; left: 50%; transform: translateX(-50%); } -gc-controller-layout-preview .gc-controller-layout-preview-btn-left { left: 0; top: 50%; transform: translateY(-50%); } - -gc-controller-layout-preview .gc-controller-layout-preview-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_controls-rebind-list.scss b/game-components/style/components/_controls-rebind-list.scss deleted file mode 100644 index 802f02a3..00000000 --- a/game-components/style/components/_controls-rebind-list.scss +++ /dev/null @@ -1,72 +0,0 @@ -gc-controls-rebind-list { - display: flex; - flex-direction: column; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-controls-rebind-list .gc-controls-rebind-list-row { - display: flex; - align-items: center; - gap: 12px; - box-sizing: border-box; - padding: 10px 14px; - border-top: 1px solid rgba(201, 169, 97, 0.15); - cursor: pointer; - user-select: none; - transition: background 0.15s ease; -} - -gc-controls-rebind-list .gc-controls-rebind-list-row:first-child { - border-top: 0; -} - -gc-controls-rebind-list .gc-controls-rebind-list-row:hover { - background: rgba(201, 169, 97, 0.08); -} - -gc-controls-rebind-list .gc-controls-rebind-list-action { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-controls-rebind-list .gc-controls-rebind-list-control { - display: flex; - align-items: center; - gap: 8px; -} - -gc-controls-rebind-list .gc-controls-rebind-list-empty { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-dim); - font-style: italic; -} - -gc-controls-rebind-list .gc-controls-rebind-list-rebind { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.24em; - text-transform: uppercase; - color: var(--fg-parch-dim); - transition: color 0.12s ease; -} - -gc-controls-rebind-list .gc-controls-rebind-list-row:hover .gc-controls-rebind-list-rebind { - color: var(--fg-gold-bright); -} - -gc-controls-rebind-list .gc-controls-rebind-list-row:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-controls-rebind-list .gc-controls-rebind-list-row:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_cooldown-badge.scss b/game-components/style/components/_cooldown-badge.scss deleted file mode 100644 index 196d80ee..00000000 --- a/game-components/style/components/_cooldown-badge.scss +++ /dev/null @@ -1,46 +0,0 @@ -gc-cooldown-badge { - --gc-cooldown-size: 48px; - --gc-cooldown-deg: 0deg; - display: inline-flex; - position: relative; - align-items: center; - justify-content: center; - width: var(--gc-cooldown-size); - height: var(--gc-cooldown-size); - border-radius: 50%; - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.6); - line-height: 0; -} - -gc-cooldown-badge .gc-cooldown-badge-ring { - position: absolute; - inset: 0; - border-radius: 50%; - background: conic-gradient(from -90deg, rgba(0, 0, 0, 0.75) var(--gc-cooldown-deg), transparent 0); - pointer-events: none; -} - -gc-cooldown-badge .gc-cooldown-badge-ring.is-ready { - background: transparent; - box-shadow: 0 0 0 1px rgba(232, 200, 120, 0.5), 0 0 10px rgba(232, 200, 120, 0.35); -} - -gc-cooldown-badge .gc-cooldown-badge-label { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--fg-mono); - font-size: calc(var(--gc-cooldown-size) * 0.3); - font-weight: 700; - letter-spacing: 0.04em; - color: #ffffff; - text-shadow: 0 1px 2px #000, 0 0 4px rgba(0, 0, 0, 0.8); - pointer-events: none; -} diff --git a/game-components/style/components/_crafting-panel.scss b/game-components/style/components/_crafting-panel.scss deleted file mode 100644 index 62f5f855..00000000 --- a/game-components/style/components/_crafting-panel.scss +++ /dev/null @@ -1,211 +0,0 @@ -gc-crafting-panel { - display: grid; - grid-template-columns: minmax(220px, 1fr) 1.4fr; - gap: 0; - box-sizing: border-box; - color: var(--fg-parch); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); -} - -gc-crafting-panel .gc-crafting-panel-list { - display: flex; - flex-direction: column; - border-right: 1px solid rgba(201, 169, 97, 0.25); - overflow-y: auto; - max-height: 460px; -} - -gc-crafting-panel .gc-crafting-panel-row { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-top: 1px solid rgba(201, 169, 97, 0.15); - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-crafting-panel .gc-crafting-panel-row:first-child { - border-top: 0; -} - -gc-crafting-panel .gc-crafting-panel-row:hover { - background: rgba(201, 169, 97, 0.08); -} - -gc-crafting-panel .gc-crafting-panel-row.is-selected { - background: linear-gradient(90deg, rgba(201, 169, 97, 0.18), transparent); - box-shadow: inset 3px 0 0 var(--fg-gold); - color: var(--fg-gold-bright); -} - -gc-crafting-panel .gc-crafting-panel-row.is-unaffordable { - color: var(--fg-parch-3); - opacity: 0.75; -} - -gc-crafting-panel .gc-crafting-panel-row-icon { - font-family: var(--fg-display); - font-size: 16px; - color: var(--fg-gold-bright); - width: 24px; - text-align: center; -} - -gc-crafting-panel .gc-crafting-panel-row-name { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -gc-crafting-panel .gc-crafting-panel-row:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-crafting-panel .gc-crafting-panel-row:focus:not(:focus-visible) { - outline: none; -} - -gc-crafting-panel .gc-crafting-panel-detail { - display: flex; - flex-direction: column; - gap: 8px; - padding: 16px 20px; -} - -gc-crafting-panel .gc-crafting-panel-detail-empty { - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-3); - font-size: 14px; -} - -gc-crafting-panel .gc-crafting-panel-output { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - background: rgba(0, 0, 0, 0.4); - border: 1px solid var(--fg-gold); - box-shadow: inset 0 1px 0 rgba(232, 200, 120, 0.2); -} - -gc-crafting-panel .gc-crafting-panel-output-icon { - font-family: var(--fg-display); - font-size: 22px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-crafting-panel .gc-crafting-panel-output-name { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 14px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-gold-bright); -} - -gc-crafting-panel .gc-crafting-panel-output-qty { - font-family: var(--fg-mono); - font-size: 13px; - color: var(--fg-parch); -} - -gc-crafting-panel .gc-crafting-panel-ingredients { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-crafting-panel .gc-crafting-panel-ingredient { - display: flex; - align-items: center; - gap: 10px; - padding: 6px 10px; - border: 1px solid rgba(201, 169, 97, 0.2); - background: rgba(0, 0, 0, 0.25); -} - -gc-crafting-panel .gc-crafting-panel-ingredient.is-insufficient { - border-color: var(--fg-blood); - background: rgba(168, 48, 42, 0.12); -} - -gc-crafting-panel .gc-crafting-panel-ingredient-icon { - font-family: var(--fg-display); - font-size: 14px; - color: var(--fg-gold-bright); - width: 20px; - text-align: center; -} - -gc-crafting-panel .gc-crafting-panel-ingredient-name { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-crafting-panel .gc-crafting-panel-ingredient-qty { - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-parch); -} - -gc-crafting-panel .gc-crafting-panel-ingredient.is-insufficient .gc-crafting-panel-ingredient-qty { - color: var(--fg-blood-bright); -} - -gc-crafting-panel .gc-crafting-panel-craft { - align-self: flex-end; - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - font-size: 12px; - padding: 10px 22px; - background: linear-gradient(180deg, #5a3a18, #2a1a0a); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-crafting-panel .gc-crafting-panel-craft:hover:not(:disabled) { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-crafting-panel .gc-crafting-panel-craft:active:not(:disabled) { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-crafting-panel .gc-crafting-panel-craft:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-crafting-panel .gc-crafting-panel-craft:disabled { - opacity: 0.45; - cursor: not-allowed; -} diff --git a/game-components/style/components/_credits-list.scss b/game-components/style/components/_credits-list.scss deleted file mode 100644 index 11d2a0de..00000000 --- a/game-components/style/components/_credits-list.scss +++ /dev/null @@ -1,36 +0,0 @@ -gc-credits-list { - display: flex; - flex-direction: column; - gap: 24px; - box-sizing: border-box; - color: var(--fg-parch); - text-align: center; -} - -gc-credits-list .gc-credits-list-section { - display: flex; - flex-direction: column; - gap: 8px; -} - -gc-credits-list .gc-credits-list-role { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-credits-list .gc-credits-list-names { - display: flex; - flex-direction: column; - gap: 2px; -} - -gc-credits-list .gc-credits-list-name { - font-family: var(--fg-display); - font-size: 16px; - letter-spacing: 0.12em; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_credits-scroll.scss b/game-components/style/components/_credits-scroll.scss deleted file mode 100644 index a477eada..00000000 --- a/game-components/style/components/_credits-scroll.scss +++ /dev/null @@ -1,85 +0,0 @@ -gc-credits-scroll { - position: relative; - display: block; - width: 100%; - height: 100%; - box-sizing: border-box; - overflow: hidden; - background: - radial-gradient(120% 90% at 50% 0%, #1f180e 0%, #0a0604 80%); - color: var(--fg-parch); - cursor: pointer; - user-select: none; -} - -gc-credits-scroll .gc-credits-scroll-viewport { - position: relative; - width: 100%; - height: 100%; - overflow: hidden; -} - -gc-credits-scroll .gc-credits-scroll-track { - position: absolute; - top: 100%; - left: 0; - right: 0; - display: flex; - flex-direction: column; - gap: 32px; - padding: 40px 24px; - text-align: center; - will-change: transform; -} - -gc-credits-scroll .gc-credits-scroll-title { - margin-bottom: 24px; -} - -gc-credits-scroll .gc-credits-scroll-section { - display: flex; - flex-direction: column; - gap: 8px; -} - -gc-credits-scroll .gc-credits-scroll-role { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-credits-scroll .gc-credits-scroll-names { - display: flex; - flex-direction: column; - gap: 2px; -} - -gc-credits-scroll .gc-credits-scroll-name { - font-family: var(--fg-display); - font-size: 16px; - letter-spacing: 0.12em; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-credits-scroll:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-credits-scroll:focus:not(:focus-visible) { - outline: none; -} - -gc-credits-scroll .gc-credits-scroll-indicator { - position: absolute; - bottom: 12px; - right: 16px; - font-family: var(--fg-mono); - font-size: 14px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; - pointer-events: none; -} diff --git a/game-components/style/components/_crosshair.scss b/game-components/style/components/_crosshair.scss deleted file mode 100644 index 856f0c25..00000000 --- a/game-components/style/components/_crosshair.scss +++ /dev/null @@ -1,76 +0,0 @@ -gc-crosshair { - --gc-crosshair-size: 24px; - --gc-crosshair-thickness: 2px; - --gc-crosshair-gap: 4px; - --gc-crosshair-color: var(--fg-parch); - position: relative; - display: inline-block; - width: var(--gc-crosshair-size); - height: var(--gc-crosshair-size); - pointer-events: none; -} - -gc-crosshair .gc-crosshair-arm { - position: absolute; - background: var(--gc-crosshair-color); - box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); -} - -gc-crosshair .gc-crosshair-arm-top { - left: 50%; - top: 0; - width: var(--gc-crosshair-thickness); - transform: translateX(-50%); -} - -gc-crosshair .gc-crosshair-arm-bottom { - left: 50%; - bottom: 0; - width: var(--gc-crosshair-thickness); - transform: translateX(-50%); -} - -gc-crosshair .gc-crosshair-arm-left { - top: 50%; - left: 0; - height: var(--gc-crosshair-thickness); - transform: translateY(-50%); -} - -gc-crosshair .gc-crosshair-arm-right { - top: 50%; - right: 0; - height: var(--gc-crosshair-thickness); - transform: translateY(-50%); -} - -gc-crosshair .gc-crosshair-dot { - position: absolute; - inset: 0; - margin: auto; - width: var(--gc-crosshair-thickness); - height: var(--gc-crosshair-thickness); - background: var(--gc-crosshair-color); - box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); -} - -gc-crosshair .gc-crosshair-circle { - position: absolute; - inset: 0; - margin: auto; - width: 60%; - height: 60%; - border: var(--gc-crosshair-thickness) solid var(--gc-crosshair-color); - border-radius: 50%; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); -} - -gc-crosshair .gc-crosshair-rune { - position: absolute; - inset: 0; - margin: auto; - width: 80%; - height: 80%; - background: var(--gc-crosshair-color); - clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%, 50% 25%, 25% 50%, 50% 75%, 75% 50%); -} diff --git a/game-components/style/components/_currency-chip.scss b/game-components/style/components/_currency-chip.scss deleted file mode 100644 index a5487828..00000000 --- a/game-components/style/components/_currency-chip.scss +++ /dev/null @@ -1,20 +0,0 @@ -gc-currency-chip { - --gc-currency-chip-color: var(--fg-gold-bright); - display: inline-flex; - align-items: center; - gap: 4px; - font-family: var(--fg-mono); - font-size: 13px; - color: var(--gc-currency-chip-color); - line-height: 1; -} - -gc-currency-chip .gc-currency-chip-glyph { - font-size: 1em; - line-height: 1; -} - -gc-currency-chip .gc-currency-chip-amount { - font-size: 1em; - letter-spacing: 0.04em; -} diff --git a/game-components/style/components/_currency-display.scss b/game-components/style/components/_currency-display.scss deleted file mode 100644 index a04247ef..00000000 --- a/game-components/style/components/_currency-display.scss +++ /dev/null @@ -1,31 +0,0 @@ -gc-currency-display { - --gc-currency-display-color: var(--fg-gold-bright); - --gc-currency-display-font-size: 18px; - display: inline-flex; - align-items: baseline; - gap: 8px; - color: var(--gc-currency-display-color); - line-height: 1; -} - -gc-currency-display .gc-currency-display-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-currency-display .gc-currency-display-icon { - font-size: var(--gc-currency-display-font-size); - line-height: 1; - color: inherit; -} - -gc-currency-display .gc-currency-display-amount { - font-family: var(--fg-mono); - font-size: var(--gc-currency-display-font-size); - letter-spacing: 0.04em; - color: inherit; - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_damage-number.scss b/game-components/style/components/_damage-number.scss deleted file mode 100644 index 688dd62e..00000000 --- a/game-components/style/components/_damage-number.scss +++ /dev/null @@ -1,56 +0,0 @@ -@keyframes gc-damage-number-float-up { - 0% { - transform: translateY(0) scale(1); - opacity: 0; - } - 20% { - opacity: 1; - transform: translateY(-12px) scale(1.1); - } - 100% { - transform: translateY(-60px) scale(0.9); - opacity: 0; - } -} - -gc-damage-number { - --gc-damage-number-duration: 700ms; - display: inline-flex; - align-items: center; - justify-content: center; - pointer-events: none; - line-height: 1; -} - -gc-damage-number .gc-damage-number-text { - font-family: var(--fg-mono); - font-size: 18px; - letter-spacing: 0.04em; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000, 0 0 4px #000; - animation: gc-damage-number-float-up var(--gc-damage-number-duration) ease-out forwards; - will-change: transform, opacity; -} - -gc-damage-number .gc-damage-number-text[data-variant="crit"] { - color: var(--fg-blood-bright); - font-size: 26px; - font-weight: 700; - letter-spacing: 0.06em; - text-shadow: 0 1px 2px #000, 0 0 8px rgba(212, 74, 58, 0.7); -} - -gc-damage-number .gc-damage-number-text[data-variant="heal"] { - color: var(--fg-stamina-bright); - text-shadow: 0 1px 2px #000, 0 0 6px rgba(168, 214, 90, 0.6); -} - -gc-damage-number .gc-damage-number-text[data-variant="miss"] { - font-family: var(--fg-display); - font-style: italic; - text-transform: uppercase; - letter-spacing: 0.18em; - font-size: 13px; - color: var(--fg-parch-dim); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_debug-overlay.scss b/game-components/style/components/_debug-overlay.scss deleted file mode 100644 index 5d87ab59..00000000 --- a/game-components/style/components/_debug-overlay.scss +++ /dev/null @@ -1,54 +0,0 @@ -gc-debug-overlay { - display: inline-block; - z-index: 1080; -} - -gc-debug-overlay .gc-debug-overlay-panel { - display: flex; - flex-direction: column; - gap: 2px; - box-sizing: border-box; - min-width: 140px; - padding: 8px 10px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.92), rgba(10, 6, 4, 0.92)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12), - 0 4px 12px rgba(0, 0, 0, 0.6); - color: var(--fg-parch); -} - -gc-debug-overlay .gc-debug-overlay-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.06em; -} - -gc-debug-overlay .gc-debug-overlay-label { - text-transform: uppercase; - letter-spacing: 0.16em; - color: var(--fg-parch-dim); - font-size: 10px; -} - -gc-debug-overlay .gc-debug-overlay-value { - color: var(--fg-parch); - text-align: right; -} - -gc-debug-overlay .gc-debug-overlay-fps.is-good .gc-debug-overlay-value { - color: var(--fg-stamina-bright); -} - -gc-debug-overlay .gc-debug-overlay-fps.is-warning .gc-debug-overlay-value { - color: var(--fg-gold-bright); -} - -gc-debug-overlay .gc-debug-overlay-fps.is-danger .gc-debug-overlay-value { - color: var(--fg-blood-bright); -} diff --git a/game-components/style/components/_dialogue-box.scss b/game-components/style/components/_dialogue-box.scss deleted file mode 100644 index cbbe549a..00000000 --- a/game-components/style/components/_dialogue-box.scss +++ /dev/null @@ -1,117 +0,0 @@ -gc-dialogue-box { - display: block; - position: relative; - box-sizing: border-box; - padding: 16px 20px 18px 20px; - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); - color: var(--fg-parch); - cursor: pointer; -} - -gc-dialogue-box .gc-dialogue-box-speaker { - display: flex; - align-items: baseline; - gap: 10px; - margin-bottom: 10px; -} - -gc-dialogue-box .gc-dialogue-box-eyebrow { - color: var(--fg-parch-dim); -} - -gc-dialogue-box .gc-dialogue-box-speaker-name { - font-family: var(--fg-display); - font-weight: 600; - font-size: 14px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); -} - -gc-dialogue-box .gc-dialogue-box-body { - position: relative; - min-height: 1.5em; - font-family: var(--fg-body); - font-size: 15px; - line-height: 1.55; - color: var(--fg-parch); -} - -gc-dialogue-box .gc-dialogue-box-text { - white-space: pre-wrap; -} - -gc-dialogue-box .gc-dialogue-box-indicator { - display: none; - margin-left: 6px; - color: var(--fg-gold-bright); - font-family: var(--fg-mono); - font-size: 14px; - line-height: 1; - animation: gc-dialogue-box-indicator-bob 0.9s ease-in-out infinite; -} - -gc-dialogue-box .gc-dialogue-box-indicator.is-visible { - display: inline-block; -} - -@keyframes gc-dialogue-box-indicator-bob { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(2px); } -} - -gc-dialogue-box .gc-dialogue-box-choices { - display: none; - flex-direction: column; - margin-top: 14px; - padding-top: 10px; - border-top: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-dialogue-box .gc-dialogue-box-choices.is-visible { - display: flex; -} - -gc-dialogue-box .gc-dialogue-box-choice { - box-sizing: border-box; - padding: 8px 10px; - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); - background: transparent; - border-left: 2px solid transparent; - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-dialogue-box .gc-dialogue-box-choice:hover:not(.is-disabled) { - color: var(--fg-gold-bright); - background: rgba(201, 169, 97, 0.08); - border-left-color: var(--fg-gold-bright); -} - -gc-dialogue-box .gc-dialogue-box-choice.is-disabled { - opacity: 0.4; - cursor: not-allowed; -} - -gc-dialogue-box .gc-dialogue-box-choice:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-dialogue-box .gc-dialogue-box-choice:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_divider.scss b/game-components/style/components/_divider.scss deleted file mode 100644 index dca1661a..00000000 --- a/game-components/style/components/_divider.scss +++ /dev/null @@ -1,30 +0,0 @@ -gc-divider { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - margin: 6px 0; -} - -gc-divider .gc-divider-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient( - 90deg, - transparent, - var(--fg-gold-deep) 30%, - var(--fg-gold) 50%, - var(--fg-gold-deep) 70%, - transparent - ); -} - -gc-divider .gc-divider-diamond { - display: inline-block; - width: 8px; - height: 8px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); - flex: 0 0 auto; -} diff --git a/game-components/style/components/_equipment-doll.scss b/game-components/style/components/_equipment-doll.scss deleted file mode 100644 index ec9d10e9..00000000 --- a/game-components/style/components/_equipment-doll.scss +++ /dev/null @@ -1,54 +0,0 @@ -gc-equipment-doll { - --gc-equipment-doll-width: 240px; - --gc-equipment-doll-height: 360px; - --gc-equipment-doll-slot-size: 56px; - display: inline-block; - box-sizing: border-box; -} - -gc-equipment-doll .gc-equipment-doll-canvas { - position: relative; - box-sizing: border-box; - width: var(--gc-equipment-doll-width); - height: var(--gc-equipment-doll-height); - background: - radial-gradient(60% 50% at 50% 30%, #4a3a22 0%, #1a1108 60%, #0a0604 100%), - linear-gradient(180deg, #1a1308 0%, #0a0604 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 6px 16px rgba(0, 0, 0, 0.7); -} - -gc-equipment-doll .gc-equipment-doll-silhouette { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; - color: rgba(201, 169, 97, 0.16); - font-family: var(--fg-display); - font-size: calc(var(--gc-equipment-doll-height) * 0.4); - text-shadow: 0 0 24px rgba(232, 200, 120, 0.25); -} - -gc-equipment-doll .gc-equipment-doll-anchor { - position: absolute; - transform: translate(-50%, -50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; -} - -gc-equipment-doll .gc-equipment-doll-slot-label { - font-family: var(--fg-display); - font-size: 9px; - letter-spacing: 0.24em; - text-transform: uppercase; - color: var(--fg-parch-dim); - text-shadow: 0 1px 2px #000; - text-align: center; -} diff --git a/game-components/style/components/_eyebrow.scss b/game-components/style/components/_eyebrow.scss deleted file mode 100644 index 802a065b..00000000 --- a/game-components/style/components/_eyebrow.scss +++ /dev/null @@ -1,10 +0,0 @@ -gc-eyebrow { - display: block; - font-family: var(--fg-display); - font-size: 0.72rem; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-parch-dim); - font-weight: 500; - line-height: 1.2; -} diff --git a/game-components/style/components/_friends-list.scss b/game-components/style/components/_friends-list.scss deleted file mode 100644 index aed5e884..00000000 --- a/game-components/style/components/_friends-list.scss +++ /dev/null @@ -1,152 +0,0 @@ -gc-friends-list { - display: flex; - flex-direction: column; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-friends-list .gc-friends-list-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 14px; - border-bottom: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-friends-list .gc-friends-list-count { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.16em; - color: var(--fg-gold-bright); -} - -gc-friends-list .gc-friends-list-body { - display: flex; - flex-direction: column; -} - -gc-friends-list .gc-friends-list-row { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-top: 1px solid rgba(201, 169, 97, 0.15); - transition: background 0.15s ease; -} - -gc-friends-list .gc-friends-list-row:first-child { - border-top: 0; -} - -gc-friends-list .gc-friends-list-row:hover { - background: rgba(201, 169, 97, 0.08); -} - -gc-friends-list .gc-friends-list-pip { - width: 8px; - height: 8px; - flex: none; - box-shadow: 0 0 0 1px #000; - background: var(--fg-parch-dim); -} - -gc-friends-list .gc-friends-list-row.is-online .gc-friends-list-pip { - background: var(--fg-stamina-bright); - box-shadow: 0 0 0 1px #000, 0 0 6px var(--fg-stamina-bright); -} - -gc-friends-list .gc-friends-list-row.is-in-game .gc-friends-list-pip { - background: var(--fg-gold-bright); - box-shadow: 0 0 0 1px #000, 0 0 6px var(--fg-gold-bright); -} - -gc-friends-list .gc-friends-list-row.is-busy .gc-friends-list-pip { - background: var(--fg-blood-bright); - box-shadow: 0 0 0 1px #000, 0 0 6px var(--fg-blood-bright); -} - -gc-friends-list .gc-friends-list-row.is-away .gc-friends-list-pip { - background: var(--fg-fire); - box-shadow: 0 0 0 1px #000, 0 0 6px var(--fg-fire); -} - -gc-friends-list .gc-friends-list-row.is-offline { - opacity: 0.6; -} - -gc-friends-list .gc-friends-list-row.is-offline .gc-friends-list-name { - font-style: italic; -} - -gc-friends-list .gc-friends-list-text { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -gc-friends-list .gc-friends-list-name { - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -gc-friends-list .gc-friends-list-activity { - font-family: var(--fg-body); - font-size: 11px; - color: var(--fg-parch-3); - font-style: italic; -} - -gc-friends-list .gc-friends-list-rank { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - color: var(--fg-gold-bright); - padding: 2px 6px; - border: 1px solid var(--fg-gold-deep); - background: rgba(0, 0, 0, 0.4); -} - -gc-friends-list .gc-friends-list-actions { - display: flex; - gap: 4px; -} - -gc-friends-list .gc-friends-list-action { - width: 24px; - height: 24px; - display: inline-flex; - align-items: center; - justify-content: center; - font-family: var(--fg-mono); - font-size: 12px; - color: var(--fg-parch); - background: linear-gradient(180deg, #3a2a1c, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 0 rgba(232, 200, 120, 0.2); - cursor: pointer; - transition: filter 0.12s ease, transform 0.12s ease, color 0.12s ease; -} - -gc-friends-list .gc-friends-list-action:hover { - color: var(--fg-gold-bright); - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-friends-list .gc-friends-list-action:active { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-friends-list .gc-friends-list-action:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} diff --git a/game-components/style/components/_gamepad-button-prompt.scss b/game-components/style/components/_gamepad-button-prompt.scss deleted file mode 100644 index a80e206c..00000000 --- a/game-components/style/components/_gamepad-button-prompt.scss +++ /dev/null @@ -1,46 +0,0 @@ -gc-gamepad-button-prompt { - --gc-gamepad-button-size: 24px; - --gc-gamepad-button-glyph-size: 14px; - --gc-gamepad-button-color: var(--fg-gold-bright); - display: inline-flex; - align-items: center; - gap: 6px; - line-height: 1; -} - -gc-gamepad-button-prompt .gc-gamepad-button-glyph { - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: var(--gc-gamepad-button-size); - height: var(--gc-gamepad-button-size); - border-radius: 50%; - background: linear-gradient(180deg, #3a2a1c, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - 0 1px 0 rgba(0, 0, 0, 0.6); - font-family: var(--fg-display); - font-weight: 700; - font-size: var(--gc-gamepad-button-glyph-size); - color: var(--gc-gamepad-button-color); - text-shadow: 0 1px 2px #000; -} - -gc-gamepad-button-prompt .gc-gamepad-button-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-gamepad-button-prompt[data-button="A"] { --gc-gamepad-button-color: var(--fg-stamina-bright); } -gc-gamepad-button-prompt[data-button="B"] { --gc-gamepad-button-color: var(--fg-blood-bright); } -gc-gamepad-button-prompt[data-button="X"] { --gc-gamepad-button-color: var(--fg-mana-bright); } -gc-gamepad-button-prompt[data-button="Y"] { --gc-gamepad-button-color: var(--fg-legendary); } -gc-gamepad-button-prompt[data-button="LB"], -gc-gamepad-button-prompt[data-button="RB"], -gc-gamepad-button-prompt[data-button="LT"], -gc-gamepad-button-prompt[data-button="RT"] { --gc-gamepad-button-color: var(--fg-parch); } diff --git a/game-components/style/components/_gilded-frame.scss b/game-components/style/components/_gilded-frame.scss deleted file mode 100644 index 67b7fa6b..00000000 --- a/game-components/style/components/_gilded-frame.scss +++ /dev/null @@ -1,41 +0,0 @@ -gc-gilded-frame { - display: block; - box-sizing: border-box; - color: var(--fg-parch); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-gilded-frame[tone="dark"] { - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); -} - -gc-gilded-frame[tone="leather"] { - background: linear-gradient(180deg, #2a1f14 0%, #1a1308 100%); -} - -gc-gilded-frame[tone="transparent"] { - background: transparent; -} - -gc-gilded-frame[padding="none"] { - padding: 0; -} - -gc-gilded-frame[padding="sm"] { - padding: 6px; -} - -gc-gilded-frame[padding="md"] { - padding: 14px; -} - -gc-gilded-frame[padding="lg"] { - padding: 20px; -} - -gc-gilded-frame[padding="xl"] { - padding: 28px; -} diff --git a/game-components/style/components/_grid.scss b/game-components/style/components/_grid.scss deleted file mode 100644 index f5758cdc..00000000 --- a/game-components/style/components/_grid.scss +++ /dev/null @@ -1,2 +0,0 @@ -.component.component-grid { -} diff --git a/game-components/style/components/_guild-panel.scss b/game-components/style/components/_guild-panel.scss deleted file mode 100644 index dbb7f238..00000000 --- a/game-components/style/components/_guild-panel.scss +++ /dev/null @@ -1,122 +0,0 @@ -gc-guild-panel { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px 20px; - box-sizing: border-box; - color: var(--fg-parch); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); -} - -gc-guild-panel .gc-guild-panel-header { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-guild-panel .gc-guild-panel-tag { - font-family: var(--fg-mono); - font-size: 13px; - color: var(--fg-gold-bright); - margin-left: 6px; -} - -gc-guild-panel .gc-guild-panel-motto { - font-family: var(--fg-body); - font-style: italic; - font-size: 13px; - color: var(--fg-parch-3); - margin: 0; -} - -gc-guild-panel .gc-guild-panel-stats { - display: flex; - gap: 16px; - padding: 8px 12px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-guild-panel .gc-guild-panel-stat { - display: flex; - flex-direction: column; - gap: 2px; -} - -gc-guild-panel .gc-guild-panel-stat-value { - font-family: var(--fg-mono); - font-size: 16px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-guild-panel .gc-guild-panel-members { - display: flex; - flex-direction: column; - max-height: 320px; - overflow-y: auto; -} - -gc-guild-panel .gc-guild-panel-member { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - border-top: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-guild-panel .gc-guild-panel-member:first-child { - border-top: 0; -} - -gc-guild-panel .gc-guild-panel-member-pip { - width: 8px; - height: 8px; - flex: none; - box-shadow: 0 0 0 1px #000; - background: var(--fg-parch-dim); -} - -gc-guild-panel .gc-guild-panel-member.is-online .gc-guild-panel-member-pip { - background: var(--fg-stamina-bright); - box-shadow: 0 0 0 1px #000, 0 0 6px var(--fg-stamina-bright); -} - -gc-guild-panel .gc-guild-panel-member.is-offline { - opacity: 0.6; -} - -gc-guild-panel .gc-guild-panel-member-name { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-guild-panel .gc-guild-panel-member-rank { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - padding: 2px 6px; - border: 1px solid var(--fg-gold-deep); - background: rgba(0, 0, 0, 0.4); -} - -gc-guild-panel .gc-guild-panel-member-contribution { - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-gold-bright); -} diff --git a/game-components/style/components/_hit-marker.scss b/game-components/style/components/_hit-marker.scss deleted file mode 100644 index 172d3e2a..00000000 --- a/game-components/style/components/_hit-marker.scss +++ /dev/null @@ -1,56 +0,0 @@ -gc-hit-marker { - --gc-hit-marker-size: 24px; - display: inline-flex; - position: relative; - align-items: center; - justify-content: center; - width: var(--gc-hit-marker-size); - height: var(--gc-hit-marker-size); - pointer-events: none; - opacity: 0; - transform: scale(0.85); - transition: opacity 0.12s ease, transform 0.12s ease; -} - -gc-hit-marker[show] { - opacity: 1; - transform: scale(1); -} - -gc-hit-marker .gc-hit-marker-svg { - width: 100%; - height: 100%; - overflow: visible; -} - -gc-hit-marker .gc-hit-marker-line { - stroke: var(--fg-parch); - stroke-width: 2; - stroke-linecap: square; - filter: drop-shadow(0 1px 2px #000); -} - -gc-hit-marker .gc-hit-marker-svg[data-variant="crit"] .gc-hit-marker-line { - stroke: var(--fg-blood-bright); - stroke-width: 2.5; - filter: drop-shadow(0 0 4px rgba(212, 74, 58, 0.7)); -} - -gc-hit-marker .gc-hit-marker-svg[data-variant="kill"] .gc-hit-marker-line { - stroke: var(--fg-blood); - stroke-width: 2.5; - filter: drop-shadow(0 0 6px rgba(168, 48, 42, 0.9)); -} - -gc-hit-marker .gc-hit-marker-glyph { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--fg-display); - font-size: calc(var(--gc-hit-marker-size) * 0.55); - color: var(--fg-blood-bright); - text-shadow: 0 0 6px rgba(212, 74, 58, 0.8), 0 1px 2px #000; - line-height: 1; -} diff --git a/game-components/style/components/_hotbar.scss b/game-components/style/components/_hotbar.scss deleted file mode 100644 index 8ab8bf65..00000000 --- a/game-components/style/components/_hotbar.scss +++ /dev/null @@ -1,17 +0,0 @@ -gc-hotbar { - display: inline-block; - box-sizing: border-box; - padding: 8px 10px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.7), rgba(10, 6, 4, 0.7)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 4px 12px rgba(0, 0, 0, 0.6); -} - -gc-hotbar .gc-hotbar-row { - display: inline-flex; - align-items: center; - gap: 6px; -} diff --git a/game-components/style/components/_icon-badge.scss b/game-components/style/components/_icon-badge.scss deleted file mode 100644 index 6681d76d..00000000 --- a/game-components/style/components/_icon-badge.scss +++ /dev/null @@ -1,26 +0,0 @@ -gc-icon-badge { - --gc-icon-badge-size: 28px; - --gc-icon-badge-glyph-size: 14px; - --gc-icon-badge-color: var(--fg-gold-bright); - --gc-icon-badge-bg: linear-gradient(180deg, #2a1f14, #0a0604); - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: var(--gc-icon-badge-size); - height: var(--gc-icon-badge-size); - background: var(--gc-icon-badge-bg); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.55); - color: var(--gc-icon-badge-color); - line-height: 1; -} - -gc-icon-badge .gc-icon-badge-glyph { - font-size: var(--gc-icon-badge-glyph-size); - line-height: 1; - color: inherit; -} diff --git a/game-components/style/components/_interact-prompt.scss b/game-components/style/components/_interact-prompt.scss deleted file mode 100644 index 1fc59444..00000000 --- a/game-components/style/components/_interact-prompt.scss +++ /dev/null @@ -1,47 +0,0 @@ -gc-interact-prompt { - display: none; - box-sizing: border-box; - padding: 8px 14px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.92), rgba(10, 6, 4, 0.92)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 4px 10px rgba(0, 0, 0, 0.6); - color: var(--fg-parch); - min-width: 160px; -} - -gc-interact-prompt[show] { - display: inline-block; -} - -gc-interact-prompt .gc-interact-prompt-row { - display: flex; - align-items: center; - gap: 10px; -} - -gc-interact-prompt .gc-interact-prompt-text { - font-family: var(--fg-display); - font-size: 12.5px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-interact-prompt .gc-interact-prompt-hold { - margin-top: 6px; - height: 4px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8); - position: relative; - overflow: hidden; -} - -gc-interact-prompt .gc-interact-prompt-hold-fill { - height: 100%; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transition: width 0.08s linear; -} diff --git a/game-components/style/components/_inventory-grid.scss b/game-components/style/components/_inventory-grid.scss deleted file mode 100644 index 01da7473..00000000 --- a/game-components/style/components/_inventory-grid.scss +++ /dev/null @@ -1,19 +0,0 @@ -gc-inventory-grid { - --gc-inventory-grid-cols: 6; - --gc-inventory-grid-cell: 56px; - display: inline-block; - box-sizing: border-box; - padding: 10px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.7), rgba(10, 6, 4, 0.7)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 4px 12px rgba(0, 0, 0, 0.6); -} - -gc-inventory-grid .gc-inventory-grid-cells { - display: grid; - grid-template-columns: repeat(var(--gc-inventory-grid-cols), var(--gc-inventory-grid-cell)); - gap: 4px; -} diff --git a/game-components/style/components/_invite-toast.scss b/game-components/style/components/_invite-toast.scss deleted file mode 100644 index 642f9594..00000000 --- a/game-components/style/components/_invite-toast.scss +++ /dev/null @@ -1,88 +0,0 @@ -gc-invite-toast { - display: none; - position: fixed; - top: 16px; - right: 16px; - z-index: 1080; - width: min(340px, calc(100vw - 32px)); -} - -gc-invite-toast[open] { - display: block; -} - -gc-invite-toast .gc-invite-toast-panel { - box-sizing: border-box; - padding: 14px 16px; - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); - color: var(--fg-parch); -} - -gc-invite-toast .gc-invite-toast-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 6px; -} - -gc-invite-toast .gc-invite-toast-eyebrow { - color: var(--fg-parch-dim); -} - -gc-invite-toast .gc-invite-toast-countdown { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.16em; - color: var(--fg-gold-bright); -} - -gc-invite-toast .gc-invite-toast-inviter { - font-family: var(--fg-display); - font-weight: 600; - font-size: 14px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - margin-bottom: 4px; -} - -gc-invite-toast .gc-invite-toast-body { - font-family: var(--fg-body); - font-size: 13px; - line-height: 1.45; - color: var(--fg-parch-3); - margin-bottom: 10px; -} - -gc-invite-toast .gc-invite-toast-bar { - box-sizing: border-box; - width: 100%; - height: 4px; - background: rgba(0, 0, 0, 0.6); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.7); - margin-bottom: 12px; - overflow: hidden; -} - -gc-invite-toast .gc-invite-toast-bar-fill { - height: 100%; - width: 100%; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold) 60%, var(--fg-gold-shadow)); - transition: width 0.6s ease-out; -} - -gc-invite-toast .gc-invite-toast-actions { - display: flex; - justify-content: flex-end; - gap: 8px; -} diff --git a/game-components/style/components/_item-compare.scss b/game-components/style/components/_item-compare.scss deleted file mode 100644 index 4487cb41..00000000 --- a/game-components/style/components/_item-compare.scss +++ /dev/null @@ -1,74 +0,0 @@ -gc-item-compare { - display: block; - box-sizing: border-box; -} - -gc-item-compare .gc-item-compare-root { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; -} - -gc-item-compare .gc-item-compare-col { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; -} - -gc-item-compare .gc-item-compare-col-label { - align-self: flex-start; -} - -gc-item-compare .gc-item-compare-empty { - box-sizing: border-box; - min-width: 240px; - padding: 24px 14px; - text-align: center; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.6), rgba(10, 6, 4, 0.6)); - border: 1px dashed var(--fg-gold-shadow); - color: var(--fg-parch-dim); - font-family: var(--fg-mono); - font-size: 12px; - letter-spacing: 0.16em; -} - -gc-item-compare .gc-item-compare-deltas { - display: flex; - flex-direction: column; - gap: 4px; - box-sizing: border-box; - padding: 8px 12px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.96), rgba(10, 6, 4, 0.96)); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.7), inset 0 1px 0 rgba(232, 200, 120, 0.18); -} - -gc-item-compare .gc-item-compare-delta-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} - -gc-item-compare .gc-item-compare-delta-label { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-3); -} - -gc-item-compare .gc-item-compare-delta-value { - font-family: var(--fg-mono); - font-size: 12px; - letter-spacing: 0.04em; -} - -gc-item-compare .gc-item-compare-delta-row[data-dir="up"] .gc-item-compare-delta-value { - color: var(--fg-stamina-bright); -} - -gc-item-compare .gc-item-compare-delta-row[data-dir="down"] .gc-item-compare-delta-value { - color: var(--fg-blood-bright); -} diff --git a/game-components/style/components/_item-slot.scss b/game-components/style/components/_item-slot.scss deleted file mode 100644 index 3d0fe746..00000000 --- a/game-components/style/components/_item-slot.scss +++ /dev/null @@ -1,157 +0,0 @@ -gc-item-slot { - --gc-item-slot-size: 56px; - --gc-item-slot-glyph-size: 24px; - --gc-item-slot-rarity: var(--fg-gold-deep); - --gc-item-slot-rarity-glow: rgba(201, 169, 97, 0.25); - position: relative; - display: inline-block; - box-sizing: border-box; - width: var(--gc-item-slot-size); - height: var(--gc-item-slot-size); - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--gc-item-slot-rarity); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.6), - inset 0 0 12px var(--gc-item-slot-rarity-glow); - color: var(--fg-parch-3); - cursor: pointer; - user-select: none; - transition: filter 0.12s ease, transform 0.12s ease; -} - -gc-item-slot[data-rarity="uncommon"] { - --gc-item-slot-rarity: var(--fg-uncommon); - --gc-item-slot-rarity-glow: rgba(95, 168, 74, 0.3); -} - -gc-item-slot[data-rarity="rare"] { - --gc-item-slot-rarity: var(--fg-rare); - --gc-item-slot-rarity-glow: rgba(74, 127, 207, 0.32); -} - -gc-item-slot[data-rarity="epic"] { - --gc-item-slot-rarity: var(--fg-epic); - --gc-item-slot-rarity-glow: rgba(164, 77, 208, 0.36); -} - -gc-item-slot[data-rarity="legendary"] { - --gc-item-slot-rarity: var(--fg-legendary); - --gc-item-slot-rarity-glow: rgba(232, 162, 58, 0.42); -} - -gc-item-slot[data-rarity="mythic"] { - --gc-item-slot-rarity: var(--fg-mythic); - --gc-item-slot-rarity-glow: rgba(224, 77, 106, 0.45); -} - -gc-item-slot[selected] { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; - box-shadow: - inset 0 0 0 1px #000, - inset 0 1px 0 rgba(232, 200, 120, 0.3), - inset 0 0 16px var(--gc-item-slot-rarity-glow), - 0 0 12px rgba(232, 200, 120, 0.5); -} - -gc-item-slot:hover:not(.is-locked) { - filter: brightness(1.1); -} - -gc-item-slot.is-locked { - cursor: not-allowed; -} - -gc-item-slot.is-empty { - color: rgba(184, 164, 126, 0.18); -} - -gc-item-slot .gc-item-slot-inner { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; -} - -gc-item-slot .gc-item-slot-glyph { - font-family: var(--fg-display); - font-size: var(--gc-item-slot-glyph-size); - line-height: 1; - color: inherit; -} - -gc-item-slot .gc-item-slot-qty { - position: absolute; - bottom: 2px; - right: 3px; - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000, 0 0 3px #000; - letter-spacing: 0.04em; -} - -gc-item-slot .gc-item-slot-equipped { - position: absolute; - top: 2px; - right: 3px; - font-family: var(--fg-mono); - font-size: 10px; - font-weight: 600; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; - letter-spacing: 0.06em; -} - -gc-item-slot .gc-item-slot-hotkey { - position: absolute; - top: 2px; - left: 3px; - font-family: var(--fg-mono); - font-size: 10px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; - letter-spacing: 0.06em; -} - -gc-item-slot .gc-item-slot-cooldown { - position: absolute; - inset: 0; - pointer-events: none; - background: conic-gradient(from -90deg, rgba(0, 0, 0, 0.75) var(--cd, 0deg), transparent 0); - display: flex; - align-items: center; - justify-content: center; -} - -gc-item-slot .gc-item-slot-cooldown-label { - font-family: var(--fg-mono); - font-size: 14px; - color: #fff; - text-shadow: 0 1px 2px #000, 0 0 4px #000; - letter-spacing: 0.04em; -} - -gc-item-slot .gc-item-slot-lock { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.55); - color: var(--fg-gold-deep); - font-size: calc(var(--gc-item-slot-size) * 0.4); - pointer-events: none; -} - -gc-item-slot:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-item-slot:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_item-tooltip.scss b/game-components/style/components/_item-tooltip.scss deleted file mode 100644 index 919fe8b9..00000000 --- a/game-components/style/components/_item-tooltip.scss +++ /dev/null @@ -1,148 +0,0 @@ -gc-item-tooltip { - --gc-item-tooltip-rarity: var(--fg-common); - --gc-item-tooltip-glow: rgba(156, 148, 137, 0.18); - position: relative; - display: block; - box-sizing: border-box; - min-width: 240px; - max-width: 320px; - padding: 12px 14px 14px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.96), rgba(10, 6, 4, 0.96)); - border: 1px solid var(--fg-gold-deep); - color: var(--fg-parch); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 0 18px var(--gc-item-tooltip-glow), - 0 8px 24px rgba(0, 0, 0, 0.7); -} - -gc-item-tooltip[data-empty] { - display: none; -} - -gc-item-tooltip[data-rarity="uncommon"] { - --gc-item-tooltip-rarity: var(--fg-uncommon); - --gc-item-tooltip-glow: rgba(95, 168, 74, 0.22); -} - -gc-item-tooltip[data-rarity="rare"] { - --gc-item-tooltip-rarity: var(--fg-rare); - --gc-item-tooltip-glow: rgba(74, 127, 207, 0.25); -} - -gc-item-tooltip[data-rarity="epic"] { - --gc-item-tooltip-rarity: var(--fg-epic); - --gc-item-tooltip-glow: rgba(164, 77, 208, 0.28); -} - -gc-item-tooltip[data-rarity="legendary"] { - --gc-item-tooltip-rarity: var(--fg-legendary); - --gc-item-tooltip-glow: rgba(232, 162, 58, 0.34); -} - -gc-item-tooltip[data-rarity="mythic"] { - --gc-item-tooltip-rarity: var(--fg-mythic); - --gc-item-tooltip-glow: rgba(224, 77, 106, 0.38); -} - -gc-item-tooltip .gc-item-tooltip-header { - display: flex; - flex-direction: column; - gap: 4px; - padding-bottom: 8px; - border-bottom: 1px solid rgba(201, 169, 97, 0.2); - margin-bottom: 8px; -} - -gc-item-tooltip .gc-item-tooltip-name { - font-family: var(--fg-display); - font-weight: 700; - font-size: 16px; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--gc-item-tooltip-rarity); - text-shadow: 0 1px 2px #000; -} - -gc-item-tooltip .gc-item-tooltip-rarity { - align-self: flex-start; -} - -gc-item-tooltip .gc-item-tooltip-stats { - display: flex; - flex-direction: column; - gap: 3px; - margin-bottom: 8px; -} - -gc-item-tooltip .gc-item-tooltip-stat { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} - -gc-item-tooltip .gc-item-tooltip-stat-label { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-3); -} - -gc-item-tooltip .gc-item-tooltip-stat-value { - font-family: var(--fg-mono); - font-size: 12px; - color: var(--fg-parch); - letter-spacing: 0.04em; -} - -gc-item-tooltip .gc-item-tooltip-reqs { - display: flex; - flex-direction: column; - gap: 2px; - padding-top: 6px; - border-top: 1px solid rgba(201, 169, 97, 0.12); - margin-bottom: 8px; -} - -gc-item-tooltip .gc-item-tooltip-req { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} - -gc-item-tooltip .gc-item-tooltip-req-label { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-item-tooltip .gc-item-tooltip-req-value { - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-parch-3); -} - -gc-item-tooltip .gc-item-tooltip-req.is-unmet .gc-item-tooltip-req-value, -gc-item-tooltip .gc-item-tooltip-req.is-unmet .gc-item-tooltip-req-label { - color: var(--fg-blood-bright); -} - -gc-item-tooltip .gc-item-tooltip-req.is-met .gc-item-tooltip-req-value { - color: var(--fg-stamina-bright); -} - -gc-item-tooltip .gc-item-tooltip-flavor { - font-family: var(--fg-body); - font-style: italic; - font-size: 13px; - line-height: 1.5; - color: var(--fg-parch-3); - padding-top: 6px; - border-top: 1px solid rgba(201, 169, 97, 0.12); -} diff --git a/game-components/style/components/_journal.scss b/game-components/style/components/_journal.scss deleted file mode 100644 index 4eef407c..00000000 --- a/game-components/style/components/_journal.scss +++ /dev/null @@ -1,205 +0,0 @@ -gc-journal { - display: grid; - grid-template-columns: minmax(220px, 1fr) 2fr; - gap: 0; - box-sizing: border-box; - color: var(--fg-parch); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); -} - -gc-journal .gc-journal-list { - display: flex; - flex-direction: column; - border-right: 1px solid rgba(201, 169, 97, 0.25); - overflow-y: auto; - max-height: 460px; -} - -gc-journal .gc-journal-row { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-top: 1px solid rgba(201, 169, 97, 0.15); - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-journal .gc-journal-row:first-child { - border-top: 0; -} - -gc-journal .gc-journal-row:hover { - background: rgba(201, 169, 97, 0.08); -} - -gc-journal .gc-journal-row.is-selected { - background: linear-gradient(90deg, rgba(201, 169, 97, 0.18), transparent); - box-shadow: inset 3px 0 0 var(--fg-gold); - color: var(--fg-gold-bright); -} - -gc-journal .gc-journal-row.is-inactive { - opacity: 0.55; -} - -gc-journal .gc-journal-row.is-completed .gc-journal-row-title { - text-decoration: line-through; - color: var(--fg-parch-3); -} - -gc-journal .gc-journal-row.is-failed .gc-journal-row-title { - color: var(--fg-blood-bright); -} - -gc-journal .gc-journal-row-state-pip { - width: 8px; - height: 8px; - flex: none; - box-shadow: 0 0 0 1px #000; - background: var(--fg-gold-bright); -} - -gc-journal .gc-journal-row.is-completed .gc-journal-row-state-pip { - background: var(--fg-stamina-bright); - box-shadow: 0 0 0 1px #000, 0 0 6px var(--fg-stamina-bright); -} - -gc-journal .gc-journal-row.is-failed .gc-journal-row-state-pip { - background: var(--fg-blood-bright); - box-shadow: 0 0 0 1px #000, 0 0 6px var(--fg-blood-bright); -} - -gc-journal .gc-journal-row.is-inactive .gc-journal-row-state-pip { - background: var(--fg-parch-dim); -} - -gc-journal .gc-journal-row-title { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -gc-journal .gc-journal-row:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-journal .gc-journal-row:focus:not(:focus-visible) { - outline: none; -} - -gc-journal .gc-journal-detail { - display: flex; - flex-direction: column; - gap: 10px; - padding: 16px 20px; - overflow-y: auto; - max-height: 460px; -} - -gc-journal .gc-journal-detail-empty { - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-3); - font-size: 14px; -} - -gc-journal .gc-journal-detail-description { - font-family: var(--fg-body); - font-size: 14px; - line-height: 1.55; - color: var(--fg-parch); - margin: 0; -} - -gc-journal .gc-journal-detail-body { - font-family: var(--fg-body); - font-style: italic; - font-size: 13px; - line-height: 1.5; - color: var(--fg-parch-3); - margin: 0; -} - -gc-journal .gc-journal-detail-objectives { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-journal .gc-journal-detail-objective { - display: flex; - align-items: center; - gap: 8px; - font-family: var(--fg-body); - font-size: 13px; - color: var(--fg-parch); -} - -gc-journal .gc-journal-detail-objective.is-completed { - color: var(--fg-stamina-bright); -} - -gc-journal .gc-journal-detail-objective.is-completed .gc-journal-detail-objective-label { - text-decoration: line-through; -} - -gc-journal .gc-journal-detail-objective.is-optional .gc-journal-detail-objective-label::after { - content: ' (Optional)'; - color: var(--fg-parch-dim); - font-style: italic; -} - -gc-journal .gc-journal-detail-objective-check { - font-family: var(--fg-mono); - font-size: 14px; - color: var(--fg-gold-bright); -} - -gc-journal .gc-journal-detail-rewards { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-journal .gc-journal-detail-reward { - display: flex; - align-items: baseline; - gap: 8px; -} - -gc-journal .gc-journal-detail-reward-icon { - font-size: 14px; - color: var(--fg-gold-bright); -} - -gc-journal .gc-journal-detail-reward-label { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-3); -} - -gc-journal .gc-journal-detail-reward-value { - font-family: var(--fg-mono); - font-size: 12px; - color: var(--fg-gold-bright); -} - -gc-journal .gc-journal-detail-state.is-completed { color: var(--fg-stamina-bright); } -gc-journal .gc-journal-detail-state.is-failed { color: var(--fg-blood-bright); } -gc-journal .gc-journal-detail-state.is-inactive { color: var(--fg-parch-dim); } diff --git a/game-components/style/components/_key-binder.scss b/game-components/style/components/_key-binder.scss deleted file mode 100644 index a8a4c668..00000000 --- a/game-components/style/components/_key-binder.scss +++ /dev/null @@ -1,59 +0,0 @@ -gc-key-binder { - display: inline-block; - cursor: pointer; - user-select: none; -} - -gc-key-binder[disabled] { - cursor: not-allowed; - opacity: 0.45; -} - -gc-key-binder .gc-key-binder-pad { - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - min-width: 64px; - height: 26px; - padding: 0 10px; - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); - background: linear-gradient(180deg, #3a2a1c, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.2), - inset 0 -2px 4px rgba(0, 0, 0, 0.5), - 0 1px 0 rgba(0, 0, 0, 0.6); - transition: filter 0.12s ease, color 0.12s ease; -} - -gc-key-binder:hover:not([disabled]) .gc-key-binder-pad { - filter: brightness(1.15); -} - -gc-key-binder .gc-key-binder-pad.is-empty { - color: var(--fg-parch-dim); - font-style: italic; -} - -gc-key-binder .gc-key-binder-pad.is-capturing { - color: var(--fg-gold-bright); - background: linear-gradient(180deg, #2a1a08, #4a2a0e); - border-color: var(--fg-gold-bright); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.3), - 0 0 12px rgba(232, 200, 120, 0.4); -} - -gc-key-binder:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-key-binder:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_key.scss b/game-components/style/components/_key.scss deleted file mode 100644 index 3f360fba..00000000 --- a/game-components/style/components/_key.scss +++ /dev/null @@ -1,19 +0,0 @@ -gc-key { - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - min-width: 22px; - height: 22px; - padding: 0 6px; - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-parch); - background: linear-gradient(180deg, #3a2a1c, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.2), - 0 1px 0 rgba(0, 0, 0, 0.6); - text-transform: uppercase; - letter-spacing: 0.04em; -} diff --git a/game-components/style/components/_kill-feed.scss b/game-components/style/components/_kill-feed.scss deleted file mode 100644 index 82eae26a..00000000 --- a/game-components/style/components/_kill-feed.scss +++ /dev/null @@ -1,47 +0,0 @@ -gc-kill-feed { - display: flex; - flex-direction: column; - gap: 4px; - box-sizing: border-box; - pointer-events: none; -} - -gc-kill-feed .gc-kill-feed-row { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - background: rgba(10, 6, 4, 0.7); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 2px 6px rgba(0, 0, 0, 0.6); - align-self: flex-end; - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -gc-kill-feed .gc-kill-feed-killer { - color: var(--gc-kill-feed-killer-color, var(--fg-gold-bright)); - text-shadow: 0 1px 2px #000; -} - -gc-kill-feed .gc-kill-feed-victim { - color: var(--gc-kill-feed-victim-color, var(--fg-blood-bright)); - text-shadow: 0 1px 2px #000; -} - -gc-kill-feed .gc-kill-feed-weapon { - font-family: var(--fg-mono); - color: var(--fg-parch); - font-size: 13px; - text-shadow: 0 1px 2px #000; -} - -gc-kill-feed .gc-kill-feed-headshot { - color: var(--fg-fire); - font-size: 12px; - text-shadow: 0 1px 2px #000, 0 0 6px var(--fg-fire); -} diff --git a/game-components/style/components/_legal-screen.scss b/game-components/style/components/_legal-screen.scss deleted file mode 100644 index 89e17b47..00000000 --- a/game-components/style/components/_legal-screen.scss +++ /dev/null @@ -1,131 +0,0 @@ -gc-legal-screen { - display: block; - box-sizing: border-box; - color: var(--fg-parch); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); -} - -gc-legal-screen .gc-legal-screen-root { - display: flex; - flex-direction: column; - box-sizing: border-box; - min-height: 480px; -} - -gc-legal-screen .gc-legal-screen-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 18px 22px 14px 22px; - border-bottom: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-legal-screen .gc-legal-screen-header-text { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-legal-screen .gc-legal-screen-eyebrow { - color: var(--fg-parch-dim); -} - -gc-legal-screen .gc-legal-screen-grid { - display: grid; - grid-template-columns: 220px 1fr; - flex: 1 1 auto; - min-height: 0; -} - -gc-legal-screen .gc-legal-screen-nav { - display: flex; - flex-direction: column; - border-right: 1px solid rgba(201, 169, 97, 0.25); - padding: 8px 0; -} - -gc-legal-screen .gc-legal-screen-nav-item { - display: block; - width: 100%; - box-sizing: border-box; - padding: 10px 18px; - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); - background: transparent; - border: 0; - border-left: 2px solid transparent; - text-align: left; - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-legal-screen .gc-legal-screen-nav-item:hover { - color: var(--fg-gold-bright); - background: rgba(201, 169, 97, 0.08); -} - -gc-legal-screen .gc-legal-screen-nav-item.is-active { - color: var(--fg-gold-bright); - border-left-color: var(--fg-gold-bright); - background: linear-gradient(90deg, rgba(201, 169, 97, 0.22), transparent 70%); -} - -gc-legal-screen .gc-legal-screen-nav-item:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-legal-screen .gc-legal-screen-nav-item:focus:not(:focus-visible) { - outline: none; -} - -gc-legal-screen .gc-legal-screen-body { - padding: 18px 24px; - overflow-y: auto; - max-height: 60vh; -} - -gc-legal-screen .gc-legal-screen-section-eyebrow { - color: var(--fg-parch-dim); -} - -gc-legal-screen .gc-legal-screen-section-title { - display: block; - margin-bottom: 12px; -} - -gc-legal-screen .gc-legal-screen-section-body { - font-family: var(--fg-body); - font-size: 14px; - line-height: 1.6; - color: var(--fg-parch); - white-space: pre-wrap; -} - -gc-legal-screen .gc-legal-screen-empty { - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-3); -} - -gc-legal-screen .gc-legal-screen-footer { - display: flex; - justify-content: flex-end; - gap: 10px; - padding: 14px 22px; - border-top: 1px solid rgba(201, 169, 97, 0.25); -} diff --git a/game-components/style/components/_letterbox-bars.scss b/game-components/style/components/_letterbox-bars.scss deleted file mode 100644 index 5669e48a..00000000 --- a/game-components/style/components/_letterbox-bars.scss +++ /dev/null @@ -1,33 +0,0 @@ -gc-letterbox-bars { - position: absolute; - inset: 0; - pointer-events: none; - z-index: 1035; -} - -gc-letterbox-bars .gc-letterbox-bar { - position: absolute; - left: 0; - right: 0; - height: var(--gc-letterbox-height, 80px); - background: var(--gc-letterbox-color, #000000); - transform: translateY(-100%); - transition: transform var(--gc-letterbox-duration, 0.4s) ease-in-out; -} - -gc-letterbox-bars .gc-letterbox-bar-top { - top: 0; -} - -gc-letterbox-bars .gc-letterbox-bar-bottom { - bottom: 0; - transform: translateY(100%); -} - -gc-letterbox-bars[show] .gc-letterbox-bar-top { - transform: translateY(0); -} - -gc-letterbox-bars[show] .gc-letterbox-bar-bottom { - transform: translateY(0); -} diff --git a/game-components/style/components/_level-header.scss b/game-components/style/components/_level-header.scss deleted file mode 100644 index 8c7de3c3..00000000 --- a/game-components/style/components/_level-header.scss +++ /dev/null @@ -1,91 +0,0 @@ -gc-level-header { - display: flex; - align-items: center; - gap: 16px; - padding: 12px 16px; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-level-header .gc-level-header-badge { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 2px; - flex: none; - width: 64px; - height: 64px; - box-sizing: border-box; - background: radial-gradient(70% 70% at 50% 30%, #6a4a2a, #2a1a0a 80%); - border: 2px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px var(--fg-gold-bright), - inset 0 0 12px rgba(0, 0, 0, 0.6), - 0 0 0 1px #000, - 0 4px 10px rgba(0, 0, 0, 0.6); -} - -gc-level-header .gc-level-header-eyebrow { - color: var(--fg-parch-dim); -} - -gc-level-header .gc-level-header-level { - font-family: var(--fg-display); - font-weight: 700; - font-size: 22px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; - line-height: 1; -} - -gc-level-header .gc-level-header-body { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} - -gc-level-header .gc-level-header-title { - margin: 0; -} - -gc-level-header .gc-level-header-bar { - position: relative; - height: 8px; - box-sizing: border-box; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.7); - overflow: hidden; -} - -gc-level-header .gc-level-header-bar-fill { - height: 100%; - background: linear-gradient(180deg, #f0d27a, #c9a961 60%, #5a4422); - box-shadow: 0 0 8px rgba(232, 200, 120, 0.4); - transition: width 0.6s ease-out; -} - -gc-level-header .gc-level-header-bar-meta { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; -} - -gc-level-header .gc-level-header-xp { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - color: var(--fg-parch); -} - -gc-level-header .gc-level-header-next { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); -} diff --git a/game-components/style/components/_level-select.scss b/game-components/style/components/_level-select.scss deleted file mode 100644 index 6d9b2968..00000000 --- a/game-components/style/components/_level-select.scss +++ /dev/null @@ -1,134 +0,0 @@ -gc-level-select { - display: inline-block; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-level-select .gc-level-select-canvas { - position: relative; - box-sizing: border-box; - background: - radial-gradient(60% 50% at 50% 30%, #4a3a22 0%, #1a1108 60%, #0a0604 100%), - linear-gradient(180deg, #1a1308 0%, #0a0604 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-level-select .gc-level-select-edges { - position: absolute; - top: 0; - left: 0; - pointer-events: none; -} - -gc-level-select .gc-level-select-edge { - stroke: var(--fg-gold-shadow); - stroke-width: 2; - stroke-dasharray: 4 4; -} - -gc-level-select .gc-level-select-edge.is-completed { - stroke: var(--fg-gold-bright); - stroke-dasharray: none; - stroke-width: 2; -} - -gc-level-select .gc-level-select-node { - position: absolute; - transform: translate(-50%, -50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - cursor: pointer; - user-select: none; - z-index: 1; -} - -gc-level-select .gc-level-select-glyph { - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--fg-display); - font-size: 18px; - color: var(--fg-gold-bright); - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.6); - text-shadow: 0 1px 2px #000; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-level-select .gc-level-select-node:hover .gc-level-select-glyph { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-level-select .gc-level-select-node.is-completed .gc-level-select-glyph { - border-color: var(--fg-gold-bright); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.25), - 0 0 12px rgba(232, 200, 120, 0.4); - color: var(--fg-stamina-bright); -} - -gc-level-select .gc-level-select-node.is-locked { - cursor: not-allowed; - opacity: 0.55; -} - -gc-level-select .gc-level-select-node.is-locked .gc-level-select-glyph { - color: var(--fg-gold-deep); -} - -gc-level-select .gc-level-select-node.is-selected .gc-level-select-glyph { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.25), - 0 0 12px rgba(232, 200, 120, 0.5); -} - -gc-level-select .gc-level-select-node:focus-visible .gc-level-select-glyph { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-level-select .gc-level-select-node:focus:not(:focus-visible) { - outline: none; -} - -gc-level-select .gc-level-select-label { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000; -} - -gc-level-select .gc-level-select-stars { - display: flex; - gap: 1px; -} - -gc-level-select .gc-level-select-star { - font-family: var(--fg-display); - font-size: 10px; - color: var(--fg-gold-deep); - line-height: 1; -} - -gc-level-select .gc-level-select-star.is-filled { - color: var(--fg-gold-bright); - text-shadow: 0 0 6px rgba(232, 200, 120, 0.6); -} diff --git a/game-components/style/components/_list-row.scss b/game-components/style/components/_list-row.scss deleted file mode 100644 index 43a0f6b2..00000000 --- a/game-components/style/components/_list-row.scss +++ /dev/null @@ -1,32 +0,0 @@ -gc-list-row { - --gc-list-row-accent: var(--fg-gold); - display: flex; - align-items: center; - gap: 12px; - box-sizing: border-box; - padding: 10px 14px; - color: var(--fg-parch); - background: transparent; - border-top: 1px solid rgba(201, 169, 97, 0.15); - cursor: pointer; - user-select: none; - transition: background 0.15s ease; -} - -gc-list-row:hover { - background: rgba(201, 169, 97, 0.08); -} - -gc-list-row[selected] { - background: linear-gradient(90deg, rgba(201, 169, 97, 0.18), transparent); - box-shadow: inset 3px 0 0 var(--gc-list-row-accent); -} - -gc-list-row:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-list-row:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_list.scss b/game-components/style/components/_list.scss deleted file mode 100644 index 80a01af8..00000000 --- a/game-components/style/components/_list.scss +++ /dev/null @@ -1,69 +0,0 @@ -gc-list { - display: flex; - flex-direction: column; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-list .gc-list-item { - display: flex; - align-items: center; - gap: 12px; - box-sizing: border-box; - padding: 10px 14px; - color: var(--fg-parch); - background: transparent; - border-top: 1px solid rgba(201, 169, 97, 0.15); - cursor: pointer; - user-select: none; - transition: background 0.15s ease; -} - -gc-list .gc-list-item:first-child { - border-top: 0; -} - -gc-list .gc-list-item:hover:not(.is-disabled) { - background: rgba(201, 169, 97, 0.08); -} - -gc-list .gc-list-item.is-selected { - background: linear-gradient(90deg, rgba(201, 169, 97, 0.18), transparent); - box-shadow: inset 3px 0 0 var(--fg-gold); -} - -gc-list .gc-list-item.is-disabled { - opacity: 0.4; - cursor: not-allowed; -} - -gc-list .gc-list-item-icon { - color: var(--fg-gold); - font-size: 16px; - line-height: 1; -} - -gc-list .gc-list-item-label { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -gc-list .gc-list-item-meta { - margin-left: auto; - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.08em; - color: var(--fg-parch-3); -} - -gc-list .gc-list-item:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-list .gc-list-item:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_loading-overlay.scss b/game-components/style/components/_loading-overlay.scss deleted file mode 100644 index 78249ac9..00000000 --- a/game-components/style/components/_loading-overlay.scss +++ /dev/null @@ -1,120 +0,0 @@ -gc-loading-overlay { - display: none; - position: fixed; - inset: 0; - z-index: 1050; -} - -gc-loading-overlay[open] { - display: block; -} - -gc-loading-overlay .gc-loading-overlay-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(2px); - z-index: 1040; -} - -gc-loading-overlay .gc-loading-overlay-panel { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1050; - box-sizing: border-box; - width: min(420px, calc(100vw - 32px)); - padding: 24px 22px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.96), rgba(10, 6, 4, 0.96)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px var(--fg-gold-shadow), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 20px 50px rgba(0, 0, 0, 0.8); - color: var(--fg-parch); - text-align: center; -} - -gc-loading-overlay .gc-loading-overlay-spinner { - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 14px; - font-family: var(--fg-display); - font-size: 28px; - color: var(--fg-gold-bright); - text-shadow: 0 0 12px rgba(232, 200, 120, 0.5); -} - -gc-loading-overlay .gc-loading-overlay-spinner-rune { - display: inline-block; - animation: gc-loading-overlay-spin 1.6s linear infinite; -} - -@keyframes gc-loading-overlay-spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -gc-loading-overlay .gc-loading-overlay-label-row { - display: flex; - align-items: baseline; - justify-content: space-between; - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); - margin-bottom: 6px; -} - -gc-loading-overlay .gc-loading-overlay-pct { - color: var(--fg-gold-bright); -} - -gc-loading-overlay .gc-loading-overlay-bar { - position: relative; - box-sizing: border-box; - height: 10px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18); - overflow: hidden; -} - -gc-loading-overlay .gc-loading-overlay-bar-fill { - position: absolute; - inset: 0 auto 0 0; - width: 0; - background: linear-gradient(180deg, #f0d27a, #c9a961 60%, #5a4422); - transition: width 0.4s ease-out; -} - -gc-loading-overlay .gc-loading-overlay-bar.is-indeterminate .gc-loading-overlay-bar-fill { - width: 30%; - animation: gc-loading-overlay-indeterminate 1.4s ease-in-out infinite; -} - -@keyframes gc-loading-overlay-indeterminate { - 0% { left: -30%; } - 100% { left: 100%; } -} - -gc-loading-overlay .gc-loading-overlay-bar-scanlines { - position: absolute; - inset: 0; - background: repeating-linear-gradient(90deg, transparent 0 9px, rgba(0, 0, 0, 0.18) 9px 10px); - pointer-events: none; -} - -gc-loading-overlay .gc-loading-overlay-tip { - margin-top: 14px; - font-family: var(--fg-body); - font-style: italic; - font-size: 13px; - line-height: 1.5; - color: var(--fg-parch-3); -} diff --git a/game-components/style/components/_loading-screen.scss b/game-components/style/components/_loading-screen.scss deleted file mode 100644 index c2505d22..00000000 --- a/game-components/style/components/_loading-screen.scss +++ /dev/null @@ -1,119 +0,0 @@ -gc-loading-screen { - display: block; - box-sizing: border-box; - width: 100%; - padding: 48px 32px; - color: var(--fg-parch); - background: - radial-gradient(120% 90% at 50% 0%, #1f180e 0%, #0a0604 80%); -} - -gc-loading-screen .gc-loading-screen-root { - display: flex; - flex-direction: column; - gap: 18px; - max-width: 720px; - margin: 0 auto; - text-align: center; -} - -gc-loading-screen .gc-loading-screen-header { - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; -} - -gc-loading-screen .gc-loading-screen-eyebrow { - display: block; - color: var(--fg-parch-dim); -} - -gc-loading-screen .gc-loading-screen-title { - display: block; - --gc-title-size: 30px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 0 #000, 0 0 18px rgba(232, 200, 120, 0.35); -} - -gc-loading-screen .gc-loading-screen-label-row { - display: flex; - align-items: baseline; - justify-content: space-between; - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); -} - -gc-loading-screen .gc-loading-screen-label { - text-align: left; -} - -gc-loading-screen .gc-loading-screen-pct { - color: var(--fg-gold-bright); -} - -gc-loading-screen .gc-loading-screen-bar { - position: relative; - box-sizing: border-box; - height: 12px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18); - overflow: hidden; -} - -gc-loading-screen .gc-loading-screen-bar-fill { - position: absolute; - inset: 0 auto 0 0; - width: 0; - background: linear-gradient(180deg, #f0d27a, #c9a961 60%, #5a4422); - transition: width 0.4s ease-out; -} - -gc-loading-screen .gc-loading-screen-bar.is-indeterminate .gc-loading-screen-bar-fill { - position: absolute; - inset: 0 auto 0 0; - width: 30%; - animation: gc-loading-screen-indeterminate 1.4s ease-in-out infinite; -} - -@keyframes gc-loading-screen-indeterminate { - 0% { left: -30%; } - 100% { left: 100%; } -} - -gc-loading-screen .gc-loading-screen-bar-scanlines { - position: absolute; - inset: 0; - background: repeating-linear-gradient(90deg, transparent 0 9px, rgba(0, 0, 0, 0.18) 9px 10px); - pointer-events: none; -} - -gc-loading-screen .gc-loading-screen-tip { - margin-top: 10px; - padding: 14px 18px; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-loading-screen .gc-loading-screen-tip-eyebrow { - display: block; - margin-bottom: 6px; - color: var(--fg-parch-dim); -} - -gc-loading-screen .gc-loading-screen-tip-body { - font-family: var(--fg-body); - font-style: italic; - font-size: 13px; - line-height: 1.5; - color: var(--fg-parch-3); -} diff --git a/game-components/style/components/_lobby.scss b/game-components/style/components/_lobby.scss deleted file mode 100644 index 5804cc27..00000000 --- a/game-components/style/components/_lobby.scss +++ /dev/null @@ -1,204 +0,0 @@ -gc-lobby { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px 20px; - box-sizing: border-box; - color: var(--fg-parch); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); -} - -gc-lobby .gc-lobby-header { - display: flex; - flex-direction: column; - gap: 2px; -} - -gc-lobby .gc-lobby-meta { - display: flex; - gap: 16px; - padding: 8px 12px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-lobby .gc-lobby-meta-cell { - display: flex; - flex-direction: column; - gap: 2px; -} - -gc-lobby .gc-lobby-meta-value { - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-gold-bright); -} - -gc-lobby .gc-lobby-slots { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 360px; - overflow-y: auto; -} - -gc-lobby .gc-lobby-slot { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-lobby .gc-lobby-slot.is-ready { - border-color: var(--fg-stamina); - background: linear-gradient(90deg, rgba(111, 159, 58, 0.18), rgba(0, 0, 0, 0.3)); -} - -gc-lobby .gc-lobby-slot.is-empty { - border-style: dashed; - color: var(--fg-parch-dim); - background: rgba(10, 6, 4, 0.4); -} - -gc-lobby .gc-lobby-name { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-lobby .gc-lobby-rank { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - color: var(--fg-gold-bright); - padding: 2px 6px; - border: 1px solid var(--fg-gold-deep); - background: rgba(0, 0, 0, 0.4); -} - -gc-lobby .gc-lobby-host { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - text-shadow: 0 0 6px rgba(232, 200, 120, 0.6); -} - -gc-lobby .gc-lobby-ready { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--fg-stamina-bright); - text-shadow: 0 0 6px rgba(168, 214, 90, 0.4); -} - -gc-lobby .gc-lobby-not-ready { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-lobby .gc-lobby-empty-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--fg-parch-dim); - font-style: italic; -} - -gc-lobby .gc-lobby-actions { - display: flex; - justify-content: flex-end; - gap: 8px; -} - -gc-lobby .gc-lobby-leave, -gc-lobby .gc-lobby-ready-btn, -gc-lobby .gc-lobby-start { - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - font-size: 12px; - padding: 10px 22px; - border: 1px solid var(--fg-gold-deep); - cursor: pointer; - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-lobby .gc-lobby-leave { - background: transparent; - color: var(--fg-parch); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.4), - inset 0 1px 0 rgba(232, 200, 120, 0.18); -} - -gc-lobby .gc-lobby-ready-btn { - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); -} - -gc-lobby .gc-lobby-ready-btn.is-active { - background: linear-gradient(180deg, #2a4818, #15240a); - border-color: var(--fg-stamina); - color: var(--fg-stamina-bright); -} - -gc-lobby .gc-lobby-start { - background: linear-gradient(180deg, #5a3a18, #2a1a0a); - border-color: var(--fg-gold); - color: var(--fg-gold-bright); -} - -gc-lobby .gc-lobby-leave:hover, -gc-lobby .gc-lobby-ready-btn:hover, -gc-lobby .gc-lobby-start:hover:not(:disabled) { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-lobby .gc-lobby-leave:active, -gc-lobby .gc-lobby-ready-btn:active, -gc-lobby .gc-lobby-start:active:not(:disabled) { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-lobby .gc-lobby-start:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -gc-lobby .gc-lobby-leave:focus-visible, -gc-lobby .gc-lobby-ready-btn:focus-visible, -gc-lobby .gc-lobby-start:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} diff --git a/game-components/style/components/_loot-list.scss b/game-components/style/components/_loot-list.scss deleted file mode 100644 index 39288e73..00000000 --- a/game-components/style/components/_loot-list.scss +++ /dev/null @@ -1,141 +0,0 @@ -gc-loot-list { - display: flex; - flex-direction: column; - box-sizing: border-box; - color: var(--fg-parch); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 4px 12px rgba(0, 0, 0, 0.5); -} - -gc-loot-list .gc-loot-list-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - border-bottom: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-loot-list .gc-loot-list-take-all { - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - font-size: 10px; - padding: 6px 12px; - background: linear-gradient(180deg, #5a3a18, #2a1a0a); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-loot-list .gc-loot-list-take-all:hover:not(:disabled) { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-loot-list .gc-loot-list-take-all:active:not(:disabled) { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-loot-list .gc-loot-list-take-all:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-loot-list .gc-loot-list-take-all:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -gc-loot-list .gc-loot-list-rows { - display: flex; - flex-direction: column; -} - -gc-loot-list .gc-loot-list-row { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - border-top: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-loot-list .gc-loot-list-row:first-child { - border-top: 0; -} - -gc-loot-list .gc-loot-list-icon { - font-family: var(--fg-display); - font-size: 16px; - color: var(--fg-gold-bright); - width: 24px; - text-align: center; - text-shadow: 0 1px 2px #000; -} - -gc-loot-list .gc-loot-list-name { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-loot-list .gc-loot-list-row.is-common .gc-loot-list-name { color: var(--fg-common); } -gc-loot-list .gc-loot-list-row.is-uncommon .gc-loot-list-name { color: var(--fg-uncommon); } -gc-loot-list .gc-loot-list-row.is-rare .gc-loot-list-name { color: var(--fg-rare); } -gc-loot-list .gc-loot-list-row.is-epic .gc-loot-list-name { color: var(--fg-epic); } -gc-loot-list .gc-loot-list-row.is-legendary .gc-loot-list-name { color: var(--fg-legendary); } -gc-loot-list .gc-loot-list-row.is-mythic .gc-loot-list-name { color: var(--fg-mythic); } - -gc-loot-list .gc-loot-list-qty { - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-parch); -} - -gc-loot-list .gc-loot-list-take { - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - font-size: 10px; - padding: 6px 12px; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-loot-list .gc-loot-list-take:hover { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-loot-list .gc-loot-list-take:active { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-loot-list .gc-loot-list-take:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} diff --git a/game-components/style/components/_loot-popup.scss b/game-components/style/components/_loot-popup.scss deleted file mode 100644 index 2329379c..00000000 --- a/game-components/style/components/_loot-popup.scss +++ /dev/null @@ -1,88 +0,0 @@ -gc-loot-popup { - display: none; - position: fixed; - inset: 0; - z-index: 1050; -} - -gc-loot-popup[open] { - display: block; -} - -gc-loot-popup .gc-loot-popup-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(2px); - z-index: 1040; -} - -gc-loot-popup .gc-loot-popup-panel { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1050; - box-sizing: border-box; - width: min(420px, calc(100vw - 32px)); - padding: 24px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.96), rgba(10, 6, 4, 0.96)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px var(--fg-gold-shadow), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 20px 50px rgba(0, 0, 0, 0.8); - color: var(--fg-parch); - text-align: center; -} - -gc-loot-popup .gc-loot-popup-eyebrow { - display: block; - color: var(--fg-parch-dim); - margin-bottom: 6px; -} - -gc-loot-popup .gc-loot-popup-title { - display: block; - margin-bottom: 14px; -} - -gc-loot-popup .gc-loot-popup-divider { - display: flex; - align-items: center; - gap: 8px; - justify-content: center; - margin: 0 0 14px 0; -} - -gc-loot-popup .gc-loot-popup-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-loot-popup .gc-loot-popup-diamond { - display: inline-block; - width: 8px; - height: 8px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); -} - -gc-loot-popup .gc-loot-popup-list { - display: block; - text-align: left; - margin-bottom: 18px; -} - -gc-loot-popup .gc-loot-popup-list .gc-loot-list-header { - display: none; -} - -gc-loot-popup .gc-loot-popup-actions { - display: flex; - gap: 10px; - justify-content: flex-end; - margin-top: 8px; -} diff --git a/game-components/style/components/_lore-text.scss b/game-components/style/components/_lore-text.scss deleted file mode 100644 index c6b67dfb..00000000 --- a/game-components/style/components/_lore-text.scss +++ /dev/null @@ -1,8 +0,0 @@ -gc-lore-text { - display: block; - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-3); - font-size: 13px; - line-height: 1.5; -} diff --git a/game-components/style/components/_main-menu.scss b/game-components/style/components/_main-menu.scss deleted file mode 100644 index c2701cd8..00000000 --- a/game-components/style/components/_main-menu.scss +++ /dev/null @@ -1,95 +0,0 @@ -gc-main-menu { - display: block; - box-sizing: border-box; - color: var(--fg-parch); - outline: none; -} - -gc-main-menu .gc-main-menu-header { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 18px; -} - -gc-main-menu .gc-main-menu-eyebrow { - color: var(--fg-parch-dim); -} - -gc-main-menu .gc-main-menu-subtitle { - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-3); - font-size: 14px; - line-height: 1.5; -} - -gc-main-menu .gc-main-menu-list { - display: flex; - flex-direction: column; -} - -gc-main-menu .gc-main-menu-item { - display: flex; - align-items: center; - gap: 10px; - box-sizing: border-box; - padding: 10px 14px; - font-family: var(--fg-display); - font-size: 14px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch); - background: transparent; - border-left: 2px solid transparent; - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-main-menu .gc-main-menu-item:hover:not(.is-disabled) { - color: var(--fg-gold-bright); - background: rgba(201, 169, 97, 0.08); -} - -gc-main-menu .gc-main-menu-item.is-selected { - color: var(--fg-gold-bright); - border-left-color: var(--fg-gold-bright); - background: linear-gradient(90deg, rgba(201, 169, 97, 0.22), transparent 70%); -} - -gc-main-menu .gc-main-menu-item.is-disabled { - opacity: 0.4; - cursor: not-allowed; -} - -gc-main-menu .gc-main-menu-caret { - color: var(--fg-gold-bright); - font-size: 10px; - line-height: 1; -} - -gc-main-menu .gc-main-menu-label { - flex: 1 1 auto; -} - -gc-main-menu .gc-main-menu-badge { - margin-left: auto; - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - background: rgba(0, 0, 0, 0.4); - padding: 2px 6px; -} - -gc-main-menu:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-main-menu:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_matchmaking-screen.scss b/game-components/style/components/_matchmaking-screen.scss deleted file mode 100644 index a657849e..00000000 --- a/game-components/style/components/_matchmaking-screen.scss +++ /dev/null @@ -1,145 +0,0 @@ -gc-matchmaking-screen { - display: block; - box-sizing: border-box; - width: 100%; - padding: 48px 32px; - color: var(--fg-parch); - background: - radial-gradient(120% 90% at 50% 0%, #1f180e 0%, #0a0604 80%); -} - -gc-matchmaking-screen .gc-matchmaking-screen-root { - display: flex; - flex-direction: column; - align-items: center; - gap: 18px; - max-width: 560px; - margin: 0 auto; - text-align: center; -} - -gc-matchmaking-screen .gc-matchmaking-screen-ring { - position: relative; - width: 96px; - height: 96px; - background: radial-gradient(70% 70% at 50% 30%, #2a1f14, #0e0905); - border: 2px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 0 12px rgba(0, 0, 0, 0.6), - 0 4px 10px rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; -} - -gc-matchmaking-screen .gc-matchmaking-screen-ring-inner { - font-family: var(--fg-display); - font-weight: 700; - font-size: 38px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-matchmaking-screen .gc-matchmaking-screen-ring.is-spin { - animation: gc-matchmaking-screen-spin 1.4s linear infinite; - border-color: var(--fg-gold); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 0 12px rgba(0, 0, 0, 0.6), - 0 0 18px rgba(232, 200, 120, 0.3); -} - -gc-matchmaking-screen .gc-matchmaking-screen-ring.is-spin .gc-matchmaking-screen-ring-inner { - animation: gc-matchmaking-screen-counter-spin 1.4s linear infinite; -} - -gc-matchmaking-screen .gc-matchmaking-screen-ring.is-found { - border-color: var(--fg-gold-bright); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 0 14px rgba(232, 200, 120, 0.4), - 0 0 22px rgba(232, 200, 120, 0.5); -} - -gc-matchmaking-screen .gc-matchmaking-screen-ring.is-failed { - border-color: var(--fg-blood); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 0 14px rgba(168, 48, 42, 0.4), - 0 0 22px rgba(168, 48, 42, 0.5); -} - -gc-matchmaking-screen .gc-matchmaking-screen-ring.is-failed .gc-matchmaking-screen-ring-inner { - color: var(--fg-blood-bright); -} - -@keyframes gc-matchmaking-screen-spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@keyframes gc-matchmaking-screen-counter-spin { - from { transform: rotate(0deg); } - to { transform: rotate(-360deg); } -} - -gc-matchmaking-screen .gc-matchmaking-screen-eyebrow { - display: block; - color: var(--fg-parch-dim); -} - -gc-matchmaking-screen .gc-matchmaking-screen-title { - display: block; - --gc-title-size: 26px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 0 #000, 0 0 18px rgba(232, 200, 120, 0.35); -} - -gc-matchmaking-screen .gc-matchmaking-screen-root[data-state="failed"] .gc-matchmaking-screen-title { - color: var(--fg-blood-bright); -} - -gc-matchmaking-screen .gc-matchmaking-screen-meta { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 16px 24px; - padding: 12px 16px; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-matchmaking-screen .gc-matchmaking-screen-meta-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - min-width: 72px; -} - -gc-matchmaking-screen .gc-matchmaking-screen-meta-label { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-matchmaking-screen .gc-matchmaking-screen-meta-value { - font-family: var(--fg-mono); - font-size: 14px; - color: var(--fg-gold-bright); -} - -gc-matchmaking-screen .gc-matchmaking-screen-actions { - display: flex; - gap: 12px; - flex-wrap: wrap; - justify-content: center; - margin-top: 8px; -} diff --git a/game-components/style/components/_menu-item.scss b/game-components/style/components/_menu-item.scss deleted file mode 100644 index 94928c62..00000000 --- a/game-components/style/components/_menu-item.scss +++ /dev/null @@ -1,62 +0,0 @@ -gc-menu-item { - display: flex; - align-items: center; - gap: 10px; - box-sizing: border-box; - padding: 10px 14px; - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); - background: transparent; - border-left: 2px solid transparent; - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-menu-item:hover:not([disabled]) { - color: var(--fg-gold-bright); - background: rgba(201, 169, 97, 0.08); -} - -gc-menu-item[selected] { - color: var(--fg-gold-bright); - border-left-color: var(--fg-gold-bright); - background: linear-gradient(90deg, rgba(201, 169, 97, 0.22), transparent 70%); -} - -gc-menu-item[disabled] { - opacity: 0.4; - cursor: not-allowed; -} - -gc-menu-item .gc-menu-item-caret { - color: var(--fg-gold-bright); - font-size: 10px; - line-height: 1; -} - -gc-menu-item .gc-menu-item-icon { - color: var(--fg-gold); - font-size: 14px; - line-height: 1; -} - -gc-menu-item .gc-menu-item-label { - flex: 1 1 auto; -} - -gc-menu-item .gc-menu-item-hotkey { - margin-left: auto; -} - -gc-menu-item:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-menu-item:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_metal-button.scss b/game-components/style/components/_metal-button.scss deleted file mode 100644 index ee0bf5a1..00000000 --- a/game-components/style/components/_metal-button.scss +++ /dev/null @@ -1,83 +0,0 @@ -gc-metal-button { - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - cursor: pointer; - user-select: none; - white-space: nowrap; - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - transition: transform 0.12s ease, filter 0.12s ease; - padding: 10px 22px; - font-size: 12.5px; -} - -gc-metal-button[size="sm"] { - padding: 6px 14px; - font-size: 11px; -} - -gc-metal-button[size="md"] { - padding: 10px 22px; - font-size: 12.5px; -} - -gc-metal-button[size="lg"] { - padding: 12px 28px; - font-size: 14px; -} - -gc-metal-button[variant="primary"] { - background: linear-gradient(180deg, #5a3a18, #2a1a0a); - border-color: var(--fg-gold); - color: var(--fg-gold-bright); -} - -gc-metal-button[variant="danger"] { - background: linear-gradient(180deg, #5a1a16, #2a0a08); - border-color: var(--fg-blood); - color: #f4c4be; -} - -gc-metal-button[variant="ghost"] { - background: transparent; - border-color: var(--fg-gold-deep); - color: var(--fg-parch); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.4), - inset 0 1px 0 rgba(232, 200, 120, 0.18); -} - -gc-metal-button:hover:not([disabled]) { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-metal-button:active:not([disabled]) { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-metal-button[disabled] { - opacity: 0.45; - cursor: not-allowed; -} - -gc-metal-button:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-metal-button:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_minimap.scss b/game-components/style/components/_minimap.scss deleted file mode 100644 index 9f414232..00000000 --- a/game-components/style/components/_minimap.scss +++ /dev/null @@ -1,84 +0,0 @@ -gc-minimap { - display: inline-block; - box-sizing: border-box; - width: var(--gc-minimap-size, 180px); - height: var(--gc-minimap-size, 180px); -} - -gc-minimap .gc-minimap-frame { - position: relative; - width: 100%; - height: 100%; - box-sizing: border-box; - background: - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); - overflow: hidden; -} - -gc-minimap .gc-minimap-surface { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - transform-origin: 50% 50%; -} - -gc-minimap .gc-minimap-bg { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-image: var(--gc-minimap-bg, none); - background-size: cover; - background-position: center; - opacity: 0.7; -} - -gc-minimap .gc-minimap-marker { - position: absolute; - width: var(--gc-minimap-marker-size, 8px); - height: var(--gc-minimap-marker-size, 8px); - background: var(--gc-minimap-marker-color, var(--fg-gold-bright)); - box-shadow: - 0 0 0 1px #000, - 0 0 6px var(--gc-minimap-marker-color, rgba(232, 200, 120, 0.6)); - transform: translate(-50%, -50%); - pointer-events: none; -} - -gc-minimap .gc-minimap-player { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - color: var(--fg-gold-bright); - font-size: 14px; - text-shadow: 0 1px 2px #000, 0 0 6px rgba(232, 200, 120, 0.6); - pointer-events: none; - z-index: 2; -} - -gc-minimap .gc-minimap-corner { - position: absolute; - width: 12px; - height: 12px; - background: - linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold) 35%, var(--fg-gold-deep) 60%, #2a1c0e); - clip-path: polygon(0 0, 100% 0, 100% 35%, 65% 35%, 65% 100%, 0 100%); - pointer-events: none; - z-index: 3; -} - -gc-minimap .gc-minimap-corner-tl { top: 0; left: 0; } -gc-minimap .gc-minimap-corner-tr { top: 0; right: 0; transform: scaleX(-1); } -gc-minimap .gc-minimap-corner-bl { bottom: 0; left: 0; transform: scaleY(-1); } -gc-minimap .gc-minimap-corner-br { bottom: 0; right: 0; transform: scale(-1, -1); } diff --git a/game-components/style/components/_mute-list.scss b/game-components/style/components/_mute-list.scss deleted file mode 100644 index 5e10c24c..00000000 --- a/game-components/style/components/_mute-list.scss +++ /dev/null @@ -1,90 +0,0 @@ -gc-mute-list { - display: flex; - flex-direction: column; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-mute-list .gc-mute-list-empty { - padding: 18px 14px; - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-3); - font-size: 13px; - text-align: center; -} - -gc-mute-list .gc-mute-list-row { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - border-top: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-mute-list .gc-mute-list-row:first-child { - border-top: 0; -} - -gc-mute-list .gc-mute-list-text { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -gc-mute-list .gc-mute-list-name { - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-mute-list .gc-mute-list-reason { - font-family: var(--fg-body); - font-size: 11px; - color: var(--fg-parch-3); - font-style: italic; -} - -gc-mute-list .gc-mute-list-time { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - color: var(--fg-parch-dim); -} - -gc-mute-list .gc-mute-list-unmute { - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - font-size: 10px; - padding: 6px 12px; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-mute-list .gc-mute-list-unmute:hover { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-mute-list .gc-mute-list-unmute:active { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-mute-list .gc-mute-list-unmute:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} diff --git a/game-components/style/components/_nav-button.scss b/game-components/style/components/_nav-button.scss deleted file mode 100644 index 621e0eee..00000000 --- a/game-components/style/components/_nav-button.scss +++ /dev/null @@ -1,44 +0,0 @@ -gc-nav-button { - --gc-nav-button-size: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: var(--gc-nav-button-size); - height: var(--gc-nav-button-size); - cursor: pointer; - user-select: none; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - font-family: var(--fg-display); - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-nav-button .gc-nav-button-glyph { - font-size: calc(var(--gc-nav-button-size) * 0.5); - line-height: 1; -} - -gc-nav-button:hover { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-nav-button:active { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-nav-button:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-nav-button:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_network-status-icon.scss b/game-components/style/components/_network-status-icon.scss deleted file mode 100644 index c6e00d49..00000000 --- a/game-components/style/components/_network-status-icon.scss +++ /dev/null @@ -1,72 +0,0 @@ -gc-network-status-icon { - --gc-network-status-icon-size: 16px; - --gc-network-status-icon-color: var(--fg-stamina-bright); - display: inline-flex; - align-items: center; - gap: 8px; - line-height: 1; -} - -gc-network-status-icon[data-tier="bad"] { - --gc-network-status-icon-color: var(--fg-blood-bright); -} - -gc-network-status-icon[data-tier="warning"] { - --gc-network-status-icon-color: var(--fg-legendary); -} - -gc-network-status-icon[data-tier="ok"] { - --gc-network-status-icon-color: var(--fg-gold-bright); -} - -gc-network-status-icon[data-tier="good"] { - --gc-network-status-icon-color: var(--fg-stamina-bright); -} - -gc-network-status-icon[data-tier="offline"] { - --gc-network-status-icon-color: var(--fg-gold-shadow); -} - -gc-network-status-icon .gc-network-status-icon-bars { - display: inline-flex; - align-items: flex-end; - gap: calc(var(--gc-network-status-icon-size) * 0.12); - height: var(--gc-network-status-icon-size); -} - -gc-network-status-icon .gc-network-status-icon-bar { - display: inline-block; - width: calc(var(--gc-network-status-icon-size) * 0.18); - background: var(--fg-gold-shadow); - opacity: 0.55; - border: 1px solid #000; - box-sizing: border-box; -} - -gc-network-status-icon .gc-network-status-icon-bar[data-step="1"] { - height: 30%; -} -gc-network-status-icon .gc-network-status-icon-bar[data-step="2"] { - height: 55%; -} -gc-network-status-icon .gc-network-status-icon-bar[data-step="3"] { - height: 78%; -} -gc-network-status-icon .gc-network-status-icon-bar[data-step="4"] { - height: 100%; -} - -gc-network-status-icon .gc-network-status-icon-bar.is-active { - background: var(--gc-network-status-icon-color); - opacity: 1; - box-shadow: 0 0 4px var(--gc-network-status-icon-color); -} - -gc-network-status-icon .gc-network-status-icon-label { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--gc-network-status-icon-color); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_objective-marker.scss b/game-components/style/components/_objective-marker.scss deleted file mode 100644 index 2172733f..00000000 --- a/game-components/style/components/_objective-marker.scss +++ /dev/null @@ -1,57 +0,0 @@ -gc-objective-marker { - position: absolute; - display: inline-flex; - flex-direction: column; - align-items: center; - gap: 4px; - pointer-events: none; - transform: translate(-50%, -100%); - color: var(--gc-objective-marker-color, var(--fg-gold-bright)); - font-family: var(--fg-display); -} - -gc-objective-marker .gc-objective-marker-glyph { - font-size: var(--gc-objective-marker-size, 22px); - line-height: 1; - color: var(--gc-objective-marker-color, var(--fg-gold-bright)); - text-shadow: - 0 1px 2px #000, - 0 0 12px var(--gc-objective-marker-color, rgba(232, 200, 120, 0.6)); -} - -gc-objective-marker[pulse] .gc-objective-marker-glyph { - animation: gc-objective-marker-pulse 1.2s ease-in-out infinite; -} - -@keyframes gc-objective-marker-pulse { - 0%, 100% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.25); opacity: 0.7; } -} - -gc-objective-marker .gc-objective-marker-text { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - background: rgba(10, 6, 4, 0.7); - padding: 3px 8px; - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 0 rgba(232, 200, 120, 0.18); -} - -gc-objective-marker .gc-objective-marker-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-objective-marker .gc-objective-marker-distance { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_page-indicator.scss b/game-components/style/components/_page-indicator.scss deleted file mode 100644 index ce1ef785..00000000 --- a/game-components/style/components/_page-indicator.scss +++ /dev/null @@ -1,42 +0,0 @@ -gc-page-indicator { - --gc-page-indicator-size: 8px; - --gc-page-indicator-gap: 8px; - --gc-page-indicator-color: var(--fg-gold-deep); - --gc-page-indicator-active-color: var(--fg-gold-bright); - display: inline-flex; - align-items: center; - gap: var(--gc-page-indicator-gap); - line-height: 0; -} - -gc-page-indicator .gc-page-indicator-dot { - width: var(--gc-page-indicator-size); - height: var(--gc-page-indicator-size); - padding: 0; - background: var(--gc-page-indicator-color); - border: 1px solid #000; - box-shadow: inset 0 1px 0 rgba(232, 200, 120, 0.18); - cursor: pointer; - transition: background 0.15s ease, transform 0.15s ease; - transform: rotate(45deg); -} - -gc-page-indicator .gc-page-indicator-dot:hover { - background: var(--gc-page-indicator-active-color); -} - -gc-page-indicator .gc-page-indicator-dot.is-active { - background: var(--gc-page-indicator-active-color); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.3), - 0 0 6px rgba(232, 200, 120, 0.5); -} - -gc-page-indicator .gc-page-indicator-dot:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-page-indicator .gc-page-indicator-dot:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_panel-header.scss b/game-components/style/components/_panel-header.scss deleted file mode 100644 index 15d8eacb..00000000 --- a/game-components/style/components/_panel-header.scss +++ /dev/null @@ -1,41 +0,0 @@ -gc-panel-header { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - padding-bottom: 10px; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-panel-header .gc-panel-header-title { - font-size: 18px; -} - -gc-panel-header[data-size="sm"] .gc-panel-header-title { font-size: 14px; } -gc-panel-header[data-size="md"] .gc-panel-header-title { font-size: 18px; } -gc-panel-header[data-size="lg"] .gc-panel-header-title { font-size: 26px; } -gc-panel-header[data-size="xl"] .gc-panel-header-title { font-size: 36px; } - -gc-panel-header .gc-panel-header-divider { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - margin-top: 4px; -} - -gc-panel-header .gc-panel-header-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-panel-header .gc-panel-header-diamond { - width: 8px; - height: 8px; - flex: none; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); -} diff --git a/game-components/style/components/_panel.scss b/game-components/style/components/_panel.scss deleted file mode 100644 index 5d92bf8f..00000000 --- a/game-components/style/components/_panel.scss +++ /dev/null @@ -1,51 +0,0 @@ -gc-panel { - display: block; - position: relative; - box-sizing: border-box; - padding: 14px; - color: var(--fg-parch); -} - -gc-panel[bordered] { - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); -} - -gc-panel[parchment] { - background: - radial-gradient(120% 100% at 30% 10%, rgba(255, 240, 200, 0.12), transparent 60%), - radial-gradient(80% 60% at 80% 90%, rgba(120, 80, 30, 0.18), transparent 70%), - linear-gradient(180deg, #e8dcc4 0%, #d6c5a3 100%); - color: var(--fg-ink); -} - -gc-panel[parchment][bordered] { - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.4), - inset 0 0 0 2px var(--fg-gold-deep), - 0 8px 24px rgba(0, 0, 0, 0.5); -} - -gc-panel[corners]::before, -gc-panel[corners]::after { - content: ""; - position: absolute; - width: 14px; - height: 14px; - pointer-events: none; - background: - linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold) 35%, var(--fg-gold-deep) 60%, #2a1c0e); - clip-path: polygon(0 0, 100% 0, 100% 35%, 65% 35%, 65% 100%, 0 100%); -} - -gc-panel[corners]::before { top: -1px; left: -1px; } -gc-panel[corners]::after { top: -1px; right: -1px; transform: scaleX(-1); } diff --git a/game-components/style/components/_particle-emitter.scss b/game-components/style/components/_particle-emitter.scss deleted file mode 100644 index 4ee5ebe8..00000000 --- a/game-components/style/components/_particle-emitter.scss +++ /dev/null @@ -1,13 +0,0 @@ -gc-particle-emitter { - display: inline-block; - position: relative; - background: linear-gradient(180deg, #1a1308 0%, #0a0604 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-particle-emitter .gc-particle-emitter-canvas { - display: block; -} diff --git a/game-components/style/components/_party-panel.scss b/game-components/style/components/_party-panel.scss deleted file mode 100644 index 1d69d217..00000000 --- a/game-components/style/components/_party-panel.scss +++ /dev/null @@ -1,164 +0,0 @@ -gc-party-panel { - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px 16px; - box-sizing: border-box; - color: var(--fg-parch); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 4px 12px rgba(0, 0, 0, 0.5); -} - -gc-party-panel .gc-party-panel-header { - display: flex; - align-items: center; - justify-content: space-between; -} - -gc-party-panel .gc-party-panel-count { - font-family: var(--fg-mono); - font-size: 12px; - color: var(--fg-gold-bright); -} - -gc-party-panel .gc-party-panel-slots { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-party-panel .gc-party-panel-slot { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - box-sizing: border-box; - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-party-panel .gc-party-panel-slot.is-filled.is-ready { - border-color: var(--fg-stamina); - background: linear-gradient(90deg, rgba(111, 159, 58, 0.18), rgba(0, 0, 0, 0.3)); -} - -gc-party-panel .gc-party-panel-host { - color: var(--fg-gold-bright); - text-shadow: 0 0 4px rgba(232, 200, 120, 0.6); -} - -gc-party-panel .gc-party-panel-name { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-party-panel .gc-party-panel-role { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); - padding: 2px 6px; - border: 1px solid rgba(201, 169, 97, 0.35); -} - -gc-party-panel .gc-party-panel-ready { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--fg-stamina-bright); - text-shadow: 0 0 6px rgba(168, 214, 90, 0.4); -} - -gc-party-panel .gc-party-panel-not-ready { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-party-panel .gc-party-panel-slot.is-empty { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - width: 100%; - color: var(--fg-parch-3); - background: linear-gradient(180deg, rgba(10, 6, 4, 0.5), rgba(26, 18, 10, 0.4)); - border-style: dashed; - cursor: pointer; - transition: filter 0.12s ease, color 0.12s ease; -} - -gc-party-panel .gc-party-panel-slot.is-empty:hover { - color: var(--fg-gold-bright); - filter: brightness(1.2); -} - -gc-party-panel .gc-party-panel-slot.is-empty:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-party-panel .gc-party-panel-empty-glyph { - font-family: var(--fg-display); - font-size: 16px; -} - -gc-party-panel .gc-party-panel-empty-label { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.2em; - text-transform: uppercase; -} - -gc-party-panel .gc-party-panel-actions { - display: flex; - justify-content: flex-end; -} - -gc-party-panel .gc-party-panel-leave { - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - font-size: 11px; - padding: 6px 14px; - background: linear-gradient(180deg, #5a1a16, #2a0a08); - color: #f4c4be; - border: 1px solid var(--fg-blood); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.15), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-party-panel .gc-party-panel-leave:hover { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-party-panel .gc-party-panel-leave:active { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-party-panel .gc-party-panel-leave:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} diff --git a/game-components/style/components/_pause-menu.scss b/game-components/style/components/_pause-menu.scss deleted file mode 100644 index dd7fca5b..00000000 --- a/game-components/style/components/_pause-menu.scss +++ /dev/null @@ -1,138 +0,0 @@ -gc-pause-menu { - display: none; - position: fixed; - inset: 0; - z-index: 1050; -} - -gc-pause-menu[open] { - display: block; -} - -gc-pause-menu .gc-pause-menu-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(2px); - z-index: 1040; -} - -gc-pause-menu .gc-pause-menu-panel { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1050; - box-sizing: border-box; - width: min(420px, calc(100vw - 32px)); - padding: 30px 24px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.96), rgba(10, 6, 4, 0.96)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px var(--fg-gold-shadow), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 20px 50px rgba(0, 0, 0, 0.8); - color: var(--fg-parch); - text-align: center; -} - -gc-pause-menu .gc-pause-menu-eyebrow { - display: block; - color: var(--fg-parch-dim); - margin-bottom: 6px; -} - -gc-pause-menu .gc-pause-menu-title { - display: block; - margin-bottom: 14px; -} - -gc-pause-menu .gc-pause-menu-divider { - display: flex; - align-items: center; - gap: 8px; - justify-content: center; - margin: 0 0 18px 0; -} - -gc-pause-menu .gc-pause-menu-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-pause-menu .gc-pause-menu-diamond { - display: inline-block; - width: 8px; - height: 8px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); -} - -gc-pause-menu .gc-pause-menu-items { - display: flex; - flex-direction: column; - margin-bottom: 18px; - text-align: left; -} - -gc-pause-menu .gc-pause-menu-item { - display: flex; - align-items: center; - gap: 10px; - box-sizing: border-box; - padding: 10px 14px; - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); - background: transparent; - border-left: 2px solid transparent; - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-pause-menu .gc-pause-menu-item:hover:not(.is-disabled) { - color: var(--fg-gold-bright); - background: rgba(201, 169, 97, 0.08); -} - -gc-pause-menu .gc-pause-menu-item.is-disabled { - opacity: 0.4; - cursor: not-allowed; -} - -gc-pause-menu .gc-pause-menu-label { - flex: 1 1 auto; -} - -gc-pause-menu .gc-pause-menu-badge { - margin-left: auto; - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - background: rgba(0, 0, 0, 0.4); - padding: 2px 6px; -} - -gc-pause-menu .gc-pause-menu-item:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-pause-menu .gc-pause-menu-item:focus:not(:focus-visible) { - outline: none; -} - -gc-pause-menu .gc-pause-menu-footer { - display: flex; - justify-content: center; - padding-top: 8px; - border-top: 1px solid rgba(201, 169, 97, 0.18); -} diff --git a/game-components/style/components/_pause-screen.scss b/game-components/style/components/_pause-screen.scss deleted file mode 100644 index bfd58e7f..00000000 --- a/game-components/style/components/_pause-screen.scss +++ /dev/null @@ -1,130 +0,0 @@ -gc-pause-screen { - display: none; - position: fixed; - inset: 0; - z-index: 1050; -} - -gc-pause-screen[open] { - display: block; -} - -gc-pause-screen .gc-pause-screen-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(2px); - z-index: 1040; -} - -gc-pause-screen .gc-pause-screen-panel { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1050; - box-sizing: border-box; - width: min(420px, calc(100vw - 32px)); - padding: 30px 24px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.96), rgba(10, 6, 4, 0.96)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px var(--fg-gold-shadow), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 20px 50px rgba(0, 0, 0, 0.8); - color: var(--fg-parch); - text-align: center; -} - -gc-pause-screen .gc-pause-screen-eyebrow { - display: block; - color: var(--fg-parch-dim); - margin-bottom: 6px; -} - -gc-pause-screen .gc-pause-screen-title { - display: block; - margin-bottom: 14px; -} - -gc-pause-screen .gc-pause-screen-divider { - display: flex; - align-items: center; - gap: 8px; - justify-content: center; - margin: 0 0 18px 0; -} - -gc-pause-screen .gc-pause-screen-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-pause-screen .gc-pause-screen-diamond { - display: inline-block; - width: 8px; - height: 8px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); -} - -gc-pause-screen .gc-pause-screen-items { - display: flex; - flex-direction: column; - text-align: left; -} - -gc-pause-screen .gc-pause-screen-item { - display: flex; - align-items: center; - gap: 10px; - box-sizing: border-box; - padding: 10px 14px; - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); - background: transparent; - border-left: 2px solid transparent; - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-pause-screen .gc-pause-screen-item:hover:not(.is-disabled) { - color: var(--fg-gold-bright); - background: rgba(201, 169, 97, 0.08); -} - -gc-pause-screen .gc-pause-screen-item.is-disabled { - opacity: 0.4; - cursor: not-allowed; -} - -gc-pause-screen .gc-pause-screen-label { - flex: 1 1 auto; -} - -gc-pause-screen .gc-pause-screen-badge { - margin-left: auto; - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - background: rgba(0, 0, 0, 0.4); - padding: 2px 6px; -} - -gc-pause-screen .gc-pause-screen-item:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-pause-screen .gc-pause-screen-item:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_perk-picker.scss b/game-components/style/components/_perk-picker.scss deleted file mode 100644 index 96b90c48..00000000 --- a/game-components/style/components/_perk-picker.scss +++ /dev/null @@ -1,98 +0,0 @@ -gc-perk-picker { - display: grid; - grid-template-columns: repeat(var(--gc-perk-picker-columns, 3), 1fr); - gap: 10px; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-perk-picker .gc-perk-picker-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - padding: 14px 12px; - box-sizing: border-box; - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 4px 12px rgba(0, 0, 0, 0.5); - cursor: pointer; - user-select: none; - transition: transform 0.12s ease, filter 0.12s ease, border-color 0.12s ease; -} - -gc-perk-picker .gc-perk-picker-card:hover:not(.is-locked) { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-perk-picker .gc-perk-picker-card.is-selected { - border-color: var(--fg-gold-bright); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.3), - 0 0 18px rgba(232, 200, 120, 0.45); -} - -gc-perk-picker .gc-perk-picker-card.is-locked { - cursor: not-allowed; - opacity: 0.5; -} - -gc-perk-picker .gc-perk-picker-card:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-perk-picker .gc-perk-picker-card:focus:not(:focus-visible) { - outline: none; -} - -gc-perk-picker .gc-perk-picker-card-icon { - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--fg-display); - font-size: 22px; - color: var(--fg-gold-bright); - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.6); - text-shadow: 0 1px 2px #000; -} - -gc-perk-picker .gc-perk-picker-card.is-selected .gc-perk-picker-card-icon { - border-color: var(--fg-gold-bright); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.3), - 0 0 12px rgba(232, 200, 120, 0.5); -} - -gc-perk-picker .gc-perk-picker-card-name { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - text-align: center; - text-shadow: 0 1px 2px #000; -} - -gc-perk-picker .gc-perk-picker-card-description { - font-family: var(--fg-body); - font-size: 12px; - line-height: 1.4; - color: var(--fg-parch-3); - text-align: center; -} diff --git a/game-components/style/components/_ping-display.scss b/game-components/style/components/_ping-display.scss deleted file mode 100644 index e90b530c..00000000 --- a/game-components/style/components/_ping-display.scss +++ /dev/null @@ -1,29 +0,0 @@ -gc-ping-display { - --gc-ping-color: var(--fg-parch-dim); - display: inline-flex; - align-items: center; - gap: 6px; - font-family: var(--fg-mono); - font-size: 11px; - color: var(--gc-ping-color); - line-height: 1; - letter-spacing: 0.08em; -} - -gc-ping-display .gc-ping-display-pip { - display: inline-block; - width: 6px; - height: 6px; - background: var(--gc-ping-color); - box-shadow: 0 0 6px var(--gc-ping-color); -} - -gc-ping-display .gc-ping-display-value { - color: inherit; - text-shadow: 0 1px 2px #000; -} - -gc-ping-display[data-tier="success"] { --gc-ping-color: var(--fg-stamina-bright); } -gc-ping-display[data-tier="warning"] { --gc-ping-color: var(--fg-legendary); } -gc-ping-display[data-tier="danger"] { --gc-ping-color: var(--fg-blood-bright); } -gc-ping-display[data-tier="unknown"] { --gc-ping-color: var(--fg-parch-dim); } diff --git a/game-components/style/components/_platform-icon.scss b/game-components/style/components/_platform-icon.scss deleted file mode 100644 index 2289a6f4..00000000 --- a/game-components/style/components/_platform-icon.scss +++ /dev/null @@ -1,30 +0,0 @@ -gc-platform-icon { - --gc-platform-icon-size: 16px; - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--fg-parch); - line-height: 1; -} - -gc-platform-icon .gc-platform-icon-glyph { - font-size: var(--gc-platform-icon-size); - line-height: 1; - color: inherit; -} - -gc-platform-icon .gc-platform-icon-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); -} - -gc-platform-icon[platform="playstation"] .gc-platform-icon-glyph { color: #5a8cf0; } -gc-platform-icon[platform="xbox"] .gc-platform-icon-glyph { color: #9fc55a; } -gc-platform-icon[platform="nintendo"] .gc-platform-icon-glyph { color: var(--fg-blood-bright); } -gc-platform-icon[platform="steam"] .gc-platform-icon-glyph { color: var(--fg-silver); } -gc-platform-icon[platform="mobile"] .gc-platform-icon-glyph { color: var(--fg-parch); } -gc-platform-icon[platform="web"] .gc-platform-icon-glyph { color: var(--fg-frost); } -gc-platform-icon[platform="pc"] .gc-platform-icon-glyph { color: var(--fg-gold-bright); } diff --git a/game-components/style/components/_player-card.scss b/game-components/style/components/_player-card.scss deleted file mode 100644 index e806ff1c..00000000 --- a/game-components/style/components/_player-card.scss +++ /dev/null @@ -1,156 +0,0 @@ -gc-player-card { - display: inline-block; - box-sizing: border-box; - width: min(360px, 100%); - padding: 16px 18px; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); - color: var(--fg-parch); -} - -gc-player-card .gc-player-card-root { - display: flex; - flex-direction: column; - gap: 10px; -} - -gc-player-card .gc-player-card-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -gc-player-card .gc-player-card-identity { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -gc-player-card .gc-player-card-name { - font-family: var(--fg-display); - font-weight: 600; - font-size: 16px; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--fg-gold-bright); -} - -gc-player-card .gc-player-card-title { - font-family: var(--fg-body); - font-style: italic; - font-size: 12px; - color: var(--fg-parch-3); -} - -gc-player-card .gc-player-card-presence { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background: rgba(0, 0, 0, 0.4); - border: 1px solid var(--fg-gold-deep); - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; -} - -gc-player-card .gc-player-card-pip { - width: 6px; - height: 6px; - background: var(--fg-parch-dim); - box-shadow: 0 0 6px var(--fg-parch-dim); -} - -gc-player-card .gc-player-card-presence[data-status="online"] .gc-player-card-pip { background: var(--fg-stamina-bright); box-shadow: 0 0 6px var(--fg-stamina-bright); } -gc-player-card .gc-player-card-presence[data-status="online"] .gc-player-card-status-label { color: var(--fg-stamina-bright); } -gc-player-card .gc-player-card-presence[data-status="in-game"] .gc-player-card-pip { background: var(--fg-gold-bright); box-shadow: 0 0 6px var(--fg-gold-bright); } -gc-player-card .gc-player-card-presence[data-status="in-game"] .gc-player-card-status-label { color: var(--fg-gold-bright); } -gc-player-card .gc-player-card-presence[data-status="away"] .gc-player-card-pip { background: var(--fg-fire); box-shadow: 0 0 6px var(--fg-fire); } -gc-player-card .gc-player-card-presence[data-status="away"] .gc-player-card-status-label { color: var(--fg-fire); } -gc-player-card .gc-player-card-presence[data-status="busy"] .gc-player-card-pip { background: var(--fg-blood); box-shadow: 0 0 6px var(--fg-blood); } -gc-player-card .gc-player-card-presence[data-status="busy"] .gc-player-card-status-label { color: var(--fg-blood-bright); } -gc-player-card .gc-player-card-presence[data-status="offline"] .gc-player-card-pip { background: var(--fg-parch-dim); box-shadow: none; } -gc-player-card .gc-player-card-presence[data-status="offline"] .gc-player-card-status-label { color: var(--fg-parch-dim); } - -gc-player-card .gc-player-card-meta { - display: flex; - align-items: center; - gap: 10px; - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -gc-player-card .gc-player-card-rank { - color: var(--fg-gold); -} - -gc-player-card .gc-player-card-level { - font-family: var(--fg-mono); - font-size: 12px; - color: var(--fg-gold-bright); -} - -gc-player-card .gc-player-card-divider { - display: flex; - align-items: center; - gap: 8px; -} - -gc-player-card .gc-player-card-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-player-card .gc-player-card-diamond { - display: inline-block; - width: 6px; - height: 6px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 4px rgba(232, 200, 120, 0.4); -} - -gc-player-card .gc-player-card-stats { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 4px 12px; -} - -gc-player-card .gc-player-card-stat { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 4px 0; - border-bottom: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-player-card .gc-player-card-stat-label { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-player-card .gc-player-card-stat-value { - font-family: var(--fg-mono); - font-size: 12px; - color: var(--fg-parch); -} - -gc-player-card .gc-player-card-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 4px; -} diff --git a/game-components/style/components/_player-frame.scss b/game-components/style/components/_player-frame.scss deleted file mode 100644 index 4b2682fb..00000000 --- a/game-components/style/components/_player-frame.scss +++ /dev/null @@ -1,57 +0,0 @@ -gc-player-frame { - display: inline-block; - box-sizing: border-box; - padding: 12px 14px; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); - color: var(--fg-parch); - min-width: 280px; -} - -gc-player-frame .gc-player-frame-root { - display: flex; - align-items: center; - gap: 12px; -} - -gc-player-frame .gc-player-frame-portrait { - flex: 0 0 auto; -} - -gc-player-frame .gc-player-frame-body { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} - -gc-player-frame .gc-player-frame-header { - display: flex; - align-items: baseline; - gap: 8px; - justify-content: space-between; -} - -gc-player-frame .gc-player-frame-name { - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-gold-bright); -} - -gc-player-frame .gc-player-frame-class { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-player-frame .gc-player-frame-bar { - display: block; -} diff --git a/game-components/style/components/_portrait.scss b/game-components/style/components/_portrait.scss deleted file mode 100644 index ca848f6e..00000000 --- a/game-components/style/components/_portrait.scss +++ /dev/null @@ -1,57 +0,0 @@ -gc-portrait { - --gc-portrait-size: 64px; - --gc-portrait-glyph-size: 28px; - --gc-portrait-ring: var(--fg-gold-bright); - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: var(--gc-portrait-size); - height: var(--gc-portrait-size); - background: radial-gradient(70% 70% at 50% 30%, #6a4a2a, #2a1a0a 80%); - border: 2px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px var(--gc-portrait-ring), - inset 0 0 12px rgba(0, 0, 0, 0.6), - 0 0 0 1px #000, - 0 4px 10px rgba(0, 0, 0, 0.6); - color: var(--fg-gold-bright); - font-family: var(--fg-display); - font-weight: 700; - text-shadow: 0 1px 2px #000; -} - -gc-portrait[circle] { - border-radius: 50%; -} - -gc-portrait .gc-portrait-glyph { - font-size: var(--gc-portrait-glyph-size); - line-height: 1; -} - -gc-portrait .gc-portrait-level { - position: absolute; - right: -4px; - bottom: -4px; - min-width: 22px; - height: 22px; - padding: 0 5px; - display: inline-flex; - align-items: center; - justify-content: center; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - 0 1px 0 rgba(0, 0, 0, 0.6); - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-gold-bright); - line-height: 1; -} - -gc-portrait[circle] .gc-portrait-level { - border-radius: 11px; -} diff --git a/game-components/style/components/_press-any-key.scss b/game-components/style/components/_press-any-key.scss deleted file mode 100644 index 79f6bed8..00000000 --- a/game-components/style/components/_press-any-key.scss +++ /dev/null @@ -1,29 +0,0 @@ -gc-press-any-key { - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - font-family: var(--fg-display); - font-size: 14px; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000, 0 0 12px rgba(232, 200, 120, 0.4); - cursor: pointer; - user-select: none; - animation: gc-press-any-key-pulse 1.6s ease-in-out infinite; -} - -gc-press-any-key:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 4px; -} - -gc-press-any-key:focus:not(:focus-visible) { - outline: none; -} - -@keyframes gc-press-any-key-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} diff --git a/game-components/style/components/_quest-tracker.scss b/game-components/style/components/_quest-tracker.scss deleted file mode 100644 index 714cb857..00000000 --- a/game-components/style/components/_quest-tracker.scss +++ /dev/null @@ -1,93 +0,0 @@ -gc-quest-tracker { - display: flex; - flex-direction: column; - gap: 8px; - padding: 10px 12px; - box-sizing: border-box; - background: rgba(10, 6, 4, 0.6); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 2px 6px rgba(0, 0, 0, 0.5); - color: var(--fg-parch); -} - -gc-quest-tracker .gc-quest-tracker-quests { - display: flex; - flex-direction: column; - gap: 10px; -} - -gc-quest-tracker .gc-quest-tracker-quest { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-quest-tracker .gc-quest-tracker-quest-name { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-quest-tracker .gc-quest-tracker-objective { - display: flex; - flex-direction: column; - gap: 2px; - padding-left: 14px; -} - -gc-quest-tracker .gc-quest-tracker-objective-row { - display: flex; - align-items: center; - gap: 6px; -} - -gc-quest-tracker .gc-quest-tracker-objective-check { - font-family: var(--fg-mono); - font-size: 12px; - color: var(--fg-gold-bright); -} - -gc-quest-tracker .gc-quest-tracker-objective-label { - flex: 1 1 auto; - font-family: var(--fg-body); - font-size: 12px; - color: var(--fg-parch); -} - -gc-quest-tracker .gc-quest-tracker-objective.is-completed .gc-quest-tracker-objective-label { - text-decoration: line-through; - color: var(--fg-stamina-bright); -} - -gc-quest-tracker .gc-quest-tracker-objective.is-optional .gc-quest-tracker-objective-label { - font-style: italic; - color: var(--fg-parch-3); -} - -gc-quest-tracker .gc-quest-tracker-objective-count { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.1em; - color: var(--fg-gold-bright); -} - -gc-quest-tracker .gc-quest-tracker-objective-bar { - position: relative; - height: 4px; - margin-left: 18px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.7); - overflow: hidden; -} - -gc-quest-tracker .gc-quest-tracker-objective-bar-fill { - height: 100%; - background: linear-gradient(180deg, #f0d27a, #c9a961 60%, #5a4422); - transition: width 0.6s ease-out; -} diff --git a/game-components/style/components/_radial-wheel.scss b/game-components/style/components/_radial-wheel.scss deleted file mode 100644 index bed986b7..00000000 --- a/game-components/style/components/_radial-wheel.scss +++ /dev/null @@ -1,87 +0,0 @@ -gc-radial-wheel { - position: fixed; - inset: 0; - display: none; - z-index: 1050; -} - -gc-radial-wheel[open] { - display: block; -} - -gc-radial-wheel .gc-radial-wheel-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.55); - backdrop-filter: blur(2px); -} - -gc-radial-wheel .gc-radial-wheel-disc { - position: absolute; - top: 50%; - left: 50%; - width: var(--gc-radial-wheel-diameter, 280px); - height: var(--gc-radial-wheel-diameter, 280px); - transform: translate(-50%, -50%); -} - -gc-radial-wheel .gc-radial-wheel-option { - position: absolute; - width: var(--gc-radial-wheel-option-size, 56px); - height: var(--gc-radial-wheel-option-size, 56px); - transform: translate(-50%, -50%); - display: flex; - align-items: center; - justify-content: center; - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--gc-radial-wheel-option-color, var(--fg-gold-deep)); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.6), - 0 4px 12px rgba(0, 0, 0, 0.6); - color: var(--gc-radial-wheel-option-color, var(--fg-gold-bright)); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease; -} - -gc-radial-wheel .gc-radial-wheel-option:hover:not(.is-disabled), -gc-radial-wheel .gc-radial-wheel-option.is-hover:not(.is-disabled) { - filter: brightness(1.2); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.3), - 0 0 18px var(--gc-radial-wheel-option-color, rgba(232, 200, 120, 0.5)); - transform: translate(-50%, -50%) scale(1.08); -} - -gc-radial-wheel .gc-radial-wheel-option.is-disabled { - opacity: 0.4; - cursor: not-allowed; -} - -gc-radial-wheel .gc-radial-wheel-option:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-radial-wheel .gc-radial-wheel-option-icon { - font-family: var(--fg-display); - font-size: 22px; - text-shadow: 0 1px 2px #000; -} - -gc-radial-wheel .gc-radial-wheel-center { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-family: var(--fg-display); - font-size: 14px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; - pointer-events: none; - text-align: center; -} diff --git a/game-components/style/components/_rarity-chip.scss b/game-components/style/components/_rarity-chip.scss deleted file mode 100644 index a712fb3c..00000000 --- a/game-components/style/components/_rarity-chip.scss +++ /dev/null @@ -1,22 +0,0 @@ -gc-rarity-chip { - --gc-rarity-color: var(--fg-common); - display: inline-flex; - align-items: center; - box-sizing: border-box; - padding: 2px 6px; - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--gc-rarity-color); - border: 1px solid var(--gc-rarity-color); - background: rgba(0, 0, 0, 0.4); - line-height: 1.4; -} - -gc-rarity-chip[rarity="common"] { --gc-rarity-color: var(--fg-common); } -gc-rarity-chip[rarity="uncommon"] { --gc-rarity-color: var(--fg-uncommon); } -gc-rarity-chip[rarity="rare"] { --gc-rarity-color: var(--fg-rare); } -gc-rarity-chip[rarity="epic"] { --gc-rarity-color: var(--fg-epic); } -gc-rarity-chip[rarity="legendary"] { --gc-rarity-color: var(--fg-legendary); } -gc-rarity-chip[rarity="mythic"] { --gc-rarity-color: var(--fg-mythic); } diff --git a/game-components/style/components/_report-player-dialog.scss b/game-components/style/components/_report-player-dialog.scss deleted file mode 100644 index 5139b976..00000000 --- a/game-components/style/components/_report-player-dialog.scss +++ /dev/null @@ -1,173 +0,0 @@ -gc-report-player-dialog { - display: none; - position: fixed; - inset: 0; - z-index: 1050; -} - -gc-report-player-dialog[open] { - display: block; -} - -gc-report-player-dialog .gc-report-player-dialog-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(2px); - z-index: 1040; -} - -gc-report-player-dialog .gc-report-player-dialog-panel { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1050; - box-sizing: border-box; - width: min(460px, calc(100vw - 32px)); - padding: 30px 24px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.96), rgba(10, 6, 4, 0.96)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px var(--fg-gold-shadow), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 20px 50px rgba(0, 0, 0, 0.8); - color: var(--fg-parch); -} - -gc-report-player-dialog .gc-report-player-dialog-eyebrow { - display: block; - color: var(--fg-parch-dim); - margin-bottom: 6px; -} - -gc-report-player-dialog .gc-report-player-dialog-title { - display: block; - margin-bottom: 14px; -} - -gc-report-player-dialog .gc-report-player-dialog-divider { - display: flex; - align-items: center; - gap: 8px; - justify-content: center; - margin: 0 0 14px 0; -} - -gc-report-player-dialog .gc-report-player-dialog-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-report-player-dialog .gc-report-player-dialog-diamond { - display: inline-block; - width: 8px; - height: 8px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); -} - -gc-report-player-dialog .gc-report-player-dialog-message { - font-family: var(--fg-body); - font-size: 14px; - line-height: 1.5; - color: var(--fg-parch-3); - margin-bottom: 14px; -} - -gc-report-player-dialog .gc-report-player-dialog-reasons { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 14px; -} - -gc-report-player-dialog .gc-report-player-dialog-reason { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - cursor: pointer; - user-select: none; - transition: background 0.15s ease; -} - -gc-report-player-dialog .gc-report-player-dialog-reason:hover { - background: rgba(201, 169, 97, 0.08); -} - -gc-report-player-dialog .gc-report-player-dialog-reason input[type="radio"] { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} - -gc-report-player-dialog .gc-report-player-dialog-radio { - width: 16px; - height: 16px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8); - position: relative; - flex: 0 0 auto; -} - -gc-report-player-dialog .gc-report-player-dialog-reason.is-checked .gc-report-player-dialog-radio::after { - content: ''; - position: absolute; - inset: 3px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3); -} - -gc-report-player-dialog .gc-report-player-dialog-reason-label { - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-report-player-dialog .gc-report-player-dialog-reason.is-checked .gc-report-player-dialog-reason-label { - color: var(--fg-gold-bright); -} - -gc-report-player-dialog .gc-report-player-dialog-reason input[type="radio"]:focus-visible + .gc-report-player-dialog-radio { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-report-player-dialog .gc-report-player-dialog-comment { - box-sizing: border-box; - width: 100%; - min-height: 80px; - padding: 10px 12px; - margin-bottom: 18px; - background: rgba(0, 0, 0, 0.5); - color: var(--fg-parch); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.6); - font-family: var(--fg-body); - font-size: 14px; - line-height: 1.5; - resize: vertical; - outline: none; -} - -gc-report-player-dialog .gc-report-player-dialog-comment:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -1px; -} - -gc-report-player-dialog .gc-report-player-dialog-actions { - display: flex; - justify-content: flex-end; - gap: 10px; -} diff --git a/game-components/style/components/_resource-bar.scss b/game-components/style/components/_resource-bar.scss deleted file mode 100644 index dab44c53..00000000 --- a/game-components/style/components/_resource-bar.scss +++ /dev/null @@ -1,124 +0,0 @@ -gc-health-bar, -gc-mana-bar, -gc-stamina-bar { - display: block; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-health-bar { - --gc-bar-base: #a8302a; - --gc-bar-bright: #e0584a; - --gc-bar-shadow: #5a1410; - --gc-bar-height: 14px; -} - -gc-mana-bar { - --gc-bar-base: #3a6cc9; - --gc-bar-bright: #7aaef0; - --gc-bar-shadow: #1a2e6a; - --gc-bar-height: 11px; -} - -gc-stamina-bar { - --gc-bar-base: #6f9f3a; - --gc-bar-bright: #a8d65a; - --gc-bar-shadow: #2a4818; - --gc-bar-height: 9px; -} - -gc-health-bar .gc-bar-label-row, -gc-mana-bar .gc-bar-label-row, -gc-stamina-bar .gc-bar-label-row { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: 4px; - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -gc-health-bar .gc-bar-label, -gc-mana-bar .gc-bar-label, -gc-stamina-bar .gc-bar-label { - color: var(--fg-parch-3); -} - -gc-health-bar .gc-bar-label-value, -gc-mana-bar .gc-bar-label-value, -gc-stamina-bar .gc-bar-label-value { - color: var(--fg-gold-bright); -} - -gc-health-bar .gc-bar-track, -gc-mana-bar .gc-bar-track, -gc-stamina-bar .gc-bar-track { - position: relative; - height: var(--gc-bar-height); - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 3px rgba(0, 0, 0, 0.8); - overflow: hidden; -} - -gc-health-bar .gc-bar-fill, -gc-mana-bar .gc-bar-fill, -gc-stamina-bar .gc-bar-fill { - position: absolute; - top: 0; - left: 0; - height: 100%; - background: linear-gradient(180deg, var(--gc-bar-bright), var(--gc-bar-base) 60%, var(--gc-bar-shadow)); - transition: width 0.4s ease-out; -} - -gc-health-bar .gc-bar-ghost, -gc-mana-bar .gc-bar-ghost, -gc-stamina-bar .gc-bar-ghost { - position: absolute; - top: 0; - left: 0; - height: 100%; - background: rgba(255, 220, 200, 0.25); - transition: width 0.6s ease-out 0.25s; -} - -gc-health-bar .gc-bar-scanlines, -gc-mana-bar .gc-bar-scanlines, -gc-stamina-bar .gc-bar-scanlines { - position: absolute; - inset: 0; - pointer-events: none; - background: repeating-linear-gradient(90deg, transparent 0 9px, rgba(0, 0, 0, 0.18) 9px 10px); -} - -gc-health-bar .gc-bar-segment, -gc-mana-bar .gc-bar-segment, -gc-stamina-bar .gc-bar-segment { - position: absolute; - top: 0; - bottom: 0; - width: 1px; - background: rgba(0, 0, 0, 0.65); - pointer-events: none; -} - -gc-health-bar .gc-bar-inline-text, -gc-mana-bar .gc-bar-inline-text, -gc-stamina-bar .gc-bar-inline-text { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-parch); - letter-spacing: 0.08em; - text-shadow: 0 1px 2px #000, 0 0 3px #000; - pointer-events: none; -} diff --git a/game-components/style/components/_result-screen.scss b/game-components/style/components/_result-screen.scss deleted file mode 100644 index 5c462caf..00000000 --- a/game-components/style/components/_result-screen.scss +++ /dev/null @@ -1,217 +0,0 @@ -gc-result-screen, -gc-game-over-screen, -gc-victory-screen { - display: block; - box-sizing: border-box; - width: 100%; - padding: 48px 32px; - color: var(--fg-parch); - background: - radial-gradient(120% 90% at 50% 0%, #1f180e 0%, #0a0604 80%); -} - -gc-result-screen .gc-result-screen-root, -gc-game-over-screen .gc-result-screen-root, -gc-victory-screen .gc-result-screen-root { - display: flex; - flex-direction: column; - align-items: center; - gap: 18px; - max-width: 720px; - margin: 0 auto; - text-align: center; -} - -gc-result-screen .gc-result-screen-eyebrow, -gc-game-over-screen .gc-result-screen-eyebrow, -gc-victory-screen .gc-result-screen-eyebrow { - display: block; - color: var(--fg-parch-dim); -} - -gc-result-screen .gc-result-screen-title, -gc-game-over-screen .gc-result-screen-title, -gc-victory-screen .gc-result-screen-title { - display: block; - --gc-title-size: 38px; - text-shadow: 0 2px 0 #000, 0 0 22px rgba(232, 200, 120, 0.35); -} - -gc-result-screen .gc-result-screen-root[data-title-color="gold"] .gc-result-screen-title, -gc-game-over-screen .gc-result-screen-root[data-title-color="gold"] .gc-result-screen-title, -gc-victory-screen .gc-result-screen-root[data-title-color="gold"] .gc-result-screen-title { - color: var(--fg-gold-bright); -} - -gc-result-screen .gc-result-screen-root[data-title-color="danger"] .gc-result-screen-title, -gc-game-over-screen .gc-result-screen-root[data-title-color="danger"] .gc-result-screen-title, -gc-victory-screen .gc-result-screen-root[data-title-color="danger"] .gc-result-screen-title { - color: var(--fg-blood-bright); - text-shadow: 0 2px 0 #000, 0 0 22px rgba(212, 74, 58, 0.45); -} - -gc-result-screen .gc-result-screen-root[data-title-color="parch"] .gc-result-screen-title, -gc-game-over-screen .gc-result-screen-root[data-title-color="parch"] .gc-result-screen-title, -gc-victory-screen .gc-result-screen-root[data-title-color="parch"] .gc-result-screen-title { - color: var(--fg-parch); -} - -gc-result-screen .gc-result-screen-divider, -gc-game-over-screen .gc-result-screen-divider, -gc-victory-screen .gc-result-screen-divider { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - width: min(420px, 80%); -} - -gc-result-screen .gc-result-screen-rule, -gc-game-over-screen .gc-result-screen-rule, -gc-victory-screen .gc-result-screen-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-result-screen .gc-result-screen-diamond, -gc-game-over-screen .gc-result-screen-diamond, -gc-victory-screen .gc-result-screen-diamond { - display: inline-block; - width: 8px; - height: 8px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); -} - -gc-result-screen .gc-result-screen-subtitle, -gc-game-over-screen .gc-result-screen-subtitle, -gc-victory-screen .gc-result-screen-subtitle { - font-family: var(--fg-body); - font-style: italic; - font-size: 14px; - line-height: 1.5; - color: var(--fg-parch-3); - max-width: 560px; -} - -gc-result-screen .gc-result-screen-stats, -gc-game-over-screen .gc-result-screen-stats, -gc-victory-screen .gc-result-screen-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 8px 16px; - width: 100%; - max-width: 560px; - padding: 14px 16px; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-result-screen .gc-result-screen-stat, -gc-game-over-screen .gc-result-screen-stat, -gc-victory-screen .gc-result-screen-stat { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 4px 0; - border-bottom: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-result-screen .gc-result-screen-stat-label, -gc-game-over-screen .gc-result-screen-stat-label, -gc-victory-screen .gc-result-screen-stat-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-result-screen .gc-result-screen-stat-value, -gc-game-over-screen .gc-result-screen-stat-value, -gc-victory-screen .gc-result-screen-stat-value { - font-family: var(--fg-mono); - font-size: 14px; - color: var(--fg-gold-bright); -} - -gc-result-screen .gc-result-screen-rewards, -gc-game-over-screen .gc-result-screen-rewards, -gc-victory-screen .gc-result-screen-rewards { - width: 100%; - max-width: 560px; - display: flex; - flex-direction: column; - gap: 8px; -} - -gc-result-screen .gc-result-screen-rewards-eyebrow, -gc-game-over-screen .gc-result-screen-rewards-eyebrow, -gc-victory-screen .gc-result-screen-rewards-eyebrow { - display: block; - color: var(--fg-parch-dim); -} - -gc-result-screen .gc-result-screen-rewards-list, -gc-game-over-screen .gc-result-screen-rewards-list, -gc-victory-screen .gc-result-screen-rewards-list { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: center; -} - -gc-result-screen .gc-result-screen-reward, -gc-game-over-screen .gc-result-screen-reward, -gc-victory-screen .gc-result-screen-reward { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 12px; - background: linear-gradient(180deg, #2a1f14, #0a0604); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18); -} - -gc-result-screen .gc-result-screen-reward-glyph, -gc-game-over-screen .gc-result-screen-reward-glyph, -gc-victory-screen .gc-result-screen-reward-glyph { - color: var(--fg-gold-bright); - font-size: 14px; -} - -gc-result-screen .gc-result-screen-reward-label, -gc-game-over-screen .gc-result-screen-reward-label, -gc-victory-screen .gc-result-screen-reward-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-result-screen .gc-result-screen-reward-amount, -gc-game-over-screen .gc-result-screen-reward-amount, -gc-victory-screen .gc-result-screen-reward-amount { - font-family: var(--fg-mono); - font-size: 13px; - color: var(--fg-gold-bright); -} - -gc-result-screen .gc-result-screen-actions, -gc-game-over-screen .gc-result-screen-actions, -gc-victory-screen .gc-result-screen-actions { - display: flex; - gap: 12px; - flex-wrap: wrap; - justify-content: center; - margin-top: 8px; -} diff --git a/game-components/style/components/_rune-corner.scss b/game-components/style/components/_rune-corner.scss deleted file mode 100644 index 38d11e00..00000000 --- a/game-components/style/components/_rune-corner.scss +++ /dev/null @@ -1,20 +0,0 @@ -gc-rune-corner { - --gc-rune-corner-size: 14px; - position: absolute; - width: var(--gc-rune-corner-size); - height: var(--gc-rune-corner-size); - pointer-events: none; - background: linear-gradient( - 135deg, - var(--fg-gold-bright), - var(--fg-gold) 35%, - var(--fg-gold-deep) 60%, - #2a1c0e - ); - clip-path: polygon(0 0, 100% 0, 100% 35%, 65% 35%, 65% 100%, 0 100%); -} - -gc-rune-corner[at="tl"] { top: 0; left: 0; } -gc-rune-corner[at="tr"] { top: 0; right: 0; transform: scaleX(-1); } -gc-rune-corner[at="bl"] { bottom: 0; left: 0; transform: scaleY(-1); } -gc-rune-corner[at="br"] { bottom: 0; right: 0; transform: scale(-1, -1); } diff --git a/game-components/style/components/_safe-area.scss b/game-components/style/components/_safe-area.scss deleted file mode 100644 index 2bf7cb94..00000000 --- a/game-components/style/components/_safe-area.scss +++ /dev/null @@ -1,9 +0,0 @@ -gc-safe-area { - --gc-safe-area-extra: 0px; - display: block; - box-sizing: border-box; - padding-top: calc(env(safe-area-inset-top, 0px) + var(--gc-safe-area-extra)); - padding-right: calc(env(safe-area-inset-right, 0px) + var(--gc-safe-area-extra)); - padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--gc-safe-area-extra)); - padding-left: calc(env(safe-area-inset-left, 0px) + var(--gc-safe-area-extra)); -} diff --git a/game-components/style/components/_save-slot-list.scss b/game-components/style/components/_save-slot-list.scss deleted file mode 100644 index 9fb176c4..00000000 --- a/game-components/style/components/_save-slot-list.scss +++ /dev/null @@ -1,169 +0,0 @@ -gc-save-slot-list { - display: flex; - flex-direction: column; - gap: 6px; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-save-slot-list .gc-save-slot-list-row { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - box-sizing: border-box; - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 2px 6px rgba(0, 0, 0, 0.5); - cursor: pointer; - user-select: none; - transition: filter 0.12s ease, border-color 0.12s ease; -} - -gc-save-slot-list .gc-save-slot-list-row:hover { - filter: brightness(1.1); -} - -gc-save-slot-list .gc-save-slot-list-row.is-selected { - border-color: var(--fg-gold-bright); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.3), - 0 0 18px rgba(232, 200, 120, 0.4); -} - -gc-save-slot-list .gc-save-slot-list-row.is-empty { - border-style: dashed; - background: linear-gradient(180deg, rgba(10, 6, 4, 0.4), rgba(26, 18, 10, 0.4)); - color: var(--fg-parch-dim); -} - -gc-save-slot-list .gc-save-slot-list-row.is-autosave { - border-color: var(--fg-bronze); - background: - linear-gradient(180deg, rgba(160, 106, 58, 0.08), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); -} - -gc-save-slot-list .gc-save-slot-list-row:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-save-slot-list .gc-save-slot-list-row:focus:not(:focus-visible) { - outline: none; -} - -gc-save-slot-list .gc-save-slot-list-text { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -gc-save-slot-list .gc-save-slot-list-header-row { - display: flex; - align-items: center; - gap: 10px; -} - -gc-save-slot-list .gc-save-slot-list-name { - font-family: var(--fg-display); - font-size: 14px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-save-slot-list .gc-save-slot-list-name.is-empty-label { - color: var(--fg-parch-dim); - font-style: italic; -} - -gc-save-slot-list .gc-save-slot-list-autosave { - font-family: var(--fg-display); - font-size: 9px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--fg-bronze); - padding: 2px 6px; - border: 1px solid var(--fg-bronze); - background: rgba(0, 0, 0, 0.4); -} - -gc-save-slot-list .gc-save-slot-list-meta-row { - display: flex; - flex-wrap: wrap; - gap: 12px; -} - -gc-save-slot-list .gc-save-slot-list-meta { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); -} - -gc-save-slot-list .gc-save-slot-list-time { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - color: var(--fg-parch-3); -} - -gc-save-slot-list .gc-save-slot-list-actions { - display: flex; - gap: 6px; -} - -gc-save-slot-list .gc-save-slot-list-action { - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - font-size: 10px; - padding: 6px 12px; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-save-slot-list .gc-save-slot-list-action.is-danger { - background: linear-gradient(180deg, #5a1a16, #2a0a08); - color: #f4c4be; - border-color: var(--fg-blood); -} - -gc-save-slot-list .gc-save-slot-list-action:hover:not(:disabled) { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-save-slot-list .gc-save-slot-list-action:active:not(:disabled) { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-save-slot-list .gc-save-slot-list-action:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-save-slot-list .gc-save-slot-list-action:disabled { - opacity: 0.45; - cursor: not-allowed; -} diff --git a/game-components/style/components/_score-display.scss b/game-components/style/components/_score-display.scss deleted file mode 100644 index 6c9e1fe2..00000000 --- a/game-components/style/components/_score-display.scss +++ /dev/null @@ -1,58 +0,0 @@ -gc-score-display { - --gc-score-display-font-size: 28px; - display: inline-flex; - flex-direction: column; - gap: 4px; - line-height: 1; -} - -gc-score-display[data-align="left"] { - align-items: flex-start; - text-align: left; -} - -gc-score-display[data-align="center"] { - align-items: center; - text-align: center; -} - -gc-score-display[data-align="right"] { - align-items: flex-end; - text-align: right; -} - -gc-score-display .gc-score-display-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-score-display .gc-score-display-row { - display: inline-flex; - align-items: baseline; - gap: 8px; -} - -gc-score-display .gc-score-display-value { - font-family: var(--fg-mono); - font-weight: 700; - font-size: var(--gc-score-display-font-size); - letter-spacing: 0.06em; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000, 0 0 12px rgba(232, 200, 120, 0.35); -} - -gc-score-display .gc-score-display-multiplier { - font-family: var(--fg-display); - font-weight: 700; - font-size: calc(var(--gc-score-display-font-size) * 0.55); - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--fg-blood-bright); - text-shadow: 0 1px 2px #000, 0 0 8px rgba(212, 74, 58, 0.5); - padding: 2px 6px; - border: 1px solid var(--fg-blood); - background: rgba(0, 0, 0, 0.4); -} diff --git a/game-components/style/components/_screen-flash.scss b/game-components/style/components/_screen-flash.scss deleted file mode 100644 index 761bd005..00000000 --- a/game-components/style/components/_screen-flash.scss +++ /dev/null @@ -1,15 +0,0 @@ -gc-screen-flash { - position: fixed; - inset: 0; - pointer-events: none; - z-index: 1090; - display: block; -} - -gc-screen-flash .gc-screen-flash-fill { - position: absolute; - inset: 0; - opacity: 0; - background: #ffffff; - mix-blend-mode: screen; -} diff --git a/game-components/style/components/_scroll-text.scss b/game-components/style/components/_scroll-text.scss deleted file mode 100644 index b17c5c4f..00000000 --- a/game-components/style/components/_scroll-text.scss +++ /dev/null @@ -1,22 +0,0 @@ -gc-scroll-text { - display: block; - box-sizing: border-box; - padding: 14px; - background: rgba(10, 6, 4, 0.6); - border: 1px solid rgba(201, 169, 97, 0.25); - font-family: var(--fg-body); - font-style: italic; - color: var(--fg-parch-3); - font-size: 13px; - line-height: 1.5; -} - -gc-scroll-text > [data-gc-scroll-title] { - font-family: var(--fg-mono); - font-style: normal; - font-size: 10px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--fg-gold); - margin-bottom: 6px; -} diff --git a/game-components/style/components/_setting-rows.scss b/game-components/style/components/_setting-rows.scss deleted file mode 100644 index eb0c6fe1..00000000 --- a/game-components/style/components/_setting-rows.scss +++ /dev/null @@ -1,235 +0,0 @@ -gc-fov-slider, -gc-deadzone-slider, -gc-volume-slider, -gc-mouse-sensitivity, -gc-toggle-row, -gc-fullscreen-toggle, -gc-invert-axis-toggle, -gc-vsync-toggle, -gc-fps-cap-select, -gc-select-row, -gc-graphics-preset-picker, -gc-reset-to-defaults { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - box-sizing: border-box; - padding: 12px 14px; - border-top: 1px solid rgba(201, 169, 97, 0.15); - color: var(--fg-parch); - min-height: 44px; -} - -.gc-setting-row .gc-setting-row-text { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - flex: 1; -} - -.gc-setting-row .gc-setting-row-label { - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -.gc-setting-row .gc-setting-row-description { - font-family: var(--fg-body); - font-style: italic; - font-size: 12px; - color: var(--fg-parch-3); - line-height: 1.4; -} - -.gc-setting-row .gc-setting-row-control { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 8px; -} - -.gc-setting-row .gc-setting-row-slider { - display: flex; - align-items: center; - gap: 10px; - min-width: 220px; -} - -.gc-setting-row .gc-setting-row-slider-input { - appearance: none; - -webkit-appearance: none; - width: 160px; - height: 8px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8); - cursor: pointer; -} - -.gc-setting-row .gc-setting-row-slider-input::-webkit-slider-thumb { - appearance: none; - -webkit-appearance: none; - width: 14px; - height: 14px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - border: 1px solid var(--fg-gold-deep); - transform: rotate(45deg); - box-shadow: 0 0 0 1px #000, 0 0 6px rgba(232, 200, 120, 0.5); -} - -.gc-setting-row .gc-setting-row-slider-input::-moz-range-thumb { - width: 14px; - height: 14px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - border: 1px solid var(--fg-gold-deep); - transform: rotate(45deg); - box-shadow: 0 0 0 1px #000, 0 0 6px rgba(232, 200, 120, 0.5); -} - -.gc-setting-row .gc-setting-row-slider-value { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.08em; - color: var(--fg-gold-bright); - min-width: 48px; - text-align: right; -} - -.gc-setting-row .gc-setting-row-volume { - align-items: center; -} - -.gc-setting-row .gc-setting-row-mute { - appearance: none; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - border: 1px solid var(--fg-gold-deep); - color: var(--fg-gold-bright); - width: 28px; - height: 28px; - cursor: pointer; - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4); - font-size: 14px; - line-height: 1; -} - -.gc-setting-row .gc-setting-row-mouse { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 240px; -} - -.gc-setting-row .gc-setting-row-mouse-row { - display: flex; - align-items: center; - gap: 10px; -} - -.gc-setting-row .gc-setting-row-mouse-key { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-dim); - min-width: 36px; -} - -.gc-setting-row .gc-setting-row-toggle { - appearance: none; - cursor: pointer; - width: 44px; - height: 22px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8); - position: relative; - padding: 0; -} - -.gc-setting-row .gc-setting-row-toggle[data-checked="true"] { - background: linear-gradient(180deg, #2a1a08, #4a2a0e); -} - -.gc-setting-row .gc-setting-row-toggle-knob { - position: absolute; - top: 1px; - left: 1px; - width: 18px; - height: 18px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0 1px 2px rgba(0, 0, 0, 0.6); - transition: left 0.15s ease; -} - -.gc-setting-row .gc-setting-row-toggle[data-checked="true"] .gc-setting-row-toggle-knob { - left: 23px; -} - -.gc-setting-row .gc-setting-row-toggle:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -.gc-setting-row .gc-setting-row-select { - appearance: none; - -webkit-appearance: none; - cursor: pointer; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4); - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; - padding: 6px 28px 6px 14px; - background-image: - linear-gradient(180deg, #3a2a1a, #1f1610), - url("data:image/svg+xml;utf8,"); - background-repeat: no-repeat, no-repeat; - background-position: 0 0, calc(100% - 10px) 50%; - background-size: 100% 100%, 10px 6px; -} - -.gc-setting-row .gc-setting-row-preset-group { - display: inline-flex; -} - -.gc-setting-row .gc-setting-row-preset { - appearance: none; - cursor: pointer; - font-family: var(--fg-display); - letter-spacing: 0.16em; - text-transform: uppercase; - font-size: 11px; - color: var(--fg-parch); - background: linear-gradient(180deg, #3a2a1a, #1f1610); - border: 1px solid var(--fg-gold-deep); - padding: 6px 14px; - margin: 0; - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4); -} - -.gc-setting-row .gc-setting-row-preset + .gc-setting-row-preset { - border-left: none; -} - -.gc-setting-row .gc-setting-row-preset[data-active="true"] { - background: linear-gradient(180deg, #5a3a18, #2a1a0a); - color: var(--fg-gold-bright); -} - -.gc-setting-row .gc-setting-row-reset { - display: inline-flex; - gap: 8px; -} diff --git a/game-components/style/components/_settings-category-list.scss b/game-components/style/components/_settings-category-list.scss deleted file mode 100644 index 03d353f0..00000000 --- a/game-components/style/components/_settings-category-list.scss +++ /dev/null @@ -1,72 +0,0 @@ -gc-settings-category-list { - display: grid; - grid-template-columns: 220px 1fr; - gap: 0; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-settings-category-list .gc-settings-category-list-nav { - display: flex; - flex-direction: column; - border-right: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-settings-category-list .gc-settings-category-list-body { - padding: 16px 20px; - min-width: 0; -} - -gc-settings-category-list .gc-settings-category { - display: flex; - align-items: center; - gap: 10px; - box-sizing: border-box; - padding: 10px 14px; - font-family: var(--fg-display); - font-size: 13px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); - background: transparent; - border: 0; - border-left: 2px solid transparent; - text-align: left; - cursor: pointer; - user-select: none; - transition: background 0.15s ease, color 0.15s ease; -} - -gc-settings-category-list .gc-settings-category:hover { - color: var(--fg-gold-bright); - background: rgba(201, 169, 97, 0.08); -} - -gc-settings-category-list .gc-settings-category.is-selected { - color: var(--fg-gold-bright); - border-left-color: var(--fg-gold-bright); - background: linear-gradient(90deg, rgba(201, 169, 97, 0.22), transparent 70%); -} - -gc-settings-category-list .gc-settings-category-icon { - color: var(--fg-gold); - font-size: 14px; - line-height: 1; -} - -gc-settings-category-list .gc-settings-category.is-selected .gc-settings-category-icon { - color: var(--fg-gold-bright); -} - -gc-settings-category-list .gc-settings-category-label { - flex: 1 1 auto; -} - -gc-settings-category-list .gc-settings-category:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-settings-category-list .gc-settings-category:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_shake-container.scss b/game-components/style/components/_shake-container.scss deleted file mode 100644 index 49f0eac6..00000000 --- a/game-components/style/components/_shake-container.scss +++ /dev/null @@ -1,8 +0,0 @@ -gc-shake-container { - display: block; -} - -gc-shake-container .gc-shake-container-inner { - display: block; - will-change: transform; -} diff --git a/game-components/style/components/_shop-panel.scss b/game-components/style/components/_shop-panel.scss deleted file mode 100644 index e79eb6ac..00000000 --- a/game-components/style/components/_shop-panel.scss +++ /dev/null @@ -1,146 +0,0 @@ -gc-shop-panel { - display: flex; - flex-direction: column; - box-sizing: border-box; - color: var(--fg-parch); - background: - linear-gradient(180deg, rgba(232, 220, 196, 0.04), rgba(0, 0, 0, 0.18)), - radial-gradient(120% 80% at 50% 0%, #2e2418 0%, #1a130c 70%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 0 0 2px var(--fg-gold-deep), - inset 0 0 0 3px var(--fg-gold-shadow), - inset 0 2px 0 rgba(255, 220, 150, 0.08), - 0 8px 24px rgba(0, 0, 0, 0.6); -} - -gc-shop-panel .gc-shop-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - border-bottom: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-shop-panel .gc-shop-panel-currency { - font-family: var(--fg-mono); - font-size: 13px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-shop-panel .gc-shop-panel-currency-value { - margin-left: 4px; -} - -gc-shop-panel .gc-shop-panel-rows { - display: flex; - flex-direction: column; -} - -gc-shop-panel .gc-shop-panel-row { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 14px; - border-top: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-shop-panel .gc-shop-panel-row:first-child { - border-top: 0; -} - -gc-shop-panel .gc-shop-panel-row.is-sold-out { - opacity: 0.55; -} - -gc-shop-panel .gc-shop-panel-row.is-unaffordable .gc-shop-panel-price-value { - color: var(--fg-blood-bright); -} - -gc-shop-panel .gc-shop-panel-row-icon { - font-family: var(--fg-display); - font-size: 18px; - color: var(--fg-gold-bright); - width: 28px; - text-align: center; - text-shadow: 0 1px 2px #000; -} - -gc-shop-panel .gc-shop-panel-row-name { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 12px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch); -} - -gc-shop-panel .gc-shop-panel-discount { - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.12em; - color: var(--fg-blood-bright); - padding: 2px 6px; - border: 1px solid var(--fg-blood); - background: rgba(168, 48, 42, 0.18); - text-transform: uppercase; -} - -gc-shop-panel .gc-shop-panel-price { - display: inline-flex; - align-items: baseline; - gap: 4px; - font-family: var(--fg-mono); - font-size: 13px; - color: var(--fg-gold-bright); -} - -gc-shop-panel .gc-shop-panel-price-old { - font-size: 10px; - color: var(--fg-parch-3); - text-decoration: line-through; -} - -gc-shop-panel .gc-shop-panel-price-value { - color: var(--fg-gold-bright); -} - -gc-shop-panel .gc-shop-panel-row-action { - font-family: var(--fg-display); - letter-spacing: 0.18em; - text-transform: uppercase; - font-weight: 600; - font-size: 11px; - padding: 6px 14px; - background: linear-gradient(180deg, #3a2a1a, #1f1610); - color: var(--fg-gold-bright); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 1px 0 rgba(232, 200, 120, 0.25), - inset 0 -8px 12px rgba(0, 0, 0, 0.4), - 0 2px 0 rgba(0, 0, 0, 0.6); - cursor: pointer; - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-shop-panel .gc-shop-panel-row-action:hover:not(:disabled) { - filter: brightness(1.15); - transform: translateY(-1px); -} - -gc-shop-panel .gc-shop-panel-row-action:active:not(:disabled) { - transform: translateY(1px); - filter: brightness(0.9); -} - -gc-shop-panel .gc-shop-panel-row-action:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-shop-panel .gc-shop-panel-row-action:disabled { - opacity: 0.45; - cursor: not-allowed; -} diff --git a/game-components/style/components/_skill-bar.scss b/game-components/style/components/_skill-bar.scss deleted file mode 100644 index 87c25fc1..00000000 --- a/game-components/style/components/_skill-bar.scss +++ /dev/null @@ -1,118 +0,0 @@ -gc-skill-bar { - --gc-skill-bar-size: 56px; - --gc-skill-bar-glyph-size: 24px; - --gc-skill-bar-gap: 6px; - display: inline-block; - box-sizing: border-box; - padding: 8px 10px; - background: linear-gradient(180deg, rgba(20, 12, 8, 0.7), rgba(10, 6, 4, 0.7)); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - 0 4px 12px rgba(0, 0, 0, 0.6); -} - -gc-skill-bar .gc-skill-bar-row { - display: inline-flex; - align-items: center; - gap: var(--gc-skill-bar-gap); -} - -gc-skill-bar .gc-skill-bar-cell { - position: relative; - box-sizing: border-box; - width: var(--gc-skill-bar-size); - height: var(--gc-skill-bar-size); - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.6); - color: var(--fg-parch-3); - cursor: pointer; - user-select: none; - transition: filter 0.12s ease, transform 0.12s ease; - display: flex; - align-items: center; - justify-content: center; -} - -gc-skill-bar .gc-skill-bar-cell:hover:not(.is-disabled) { - filter: brightness(1.1); -} - -gc-skill-bar .gc-skill-bar-cell.is-selected { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; - box-shadow: - inset 0 0 0 1px #000, - inset 0 1px 0 rgba(232, 200, 120, 0.3), - 0 0 12px rgba(232, 200, 120, 0.5); -} - -gc-skill-bar .gc-skill-bar-cell.is-disabled { - opacity: 0.4; - cursor: not-allowed; -} - -gc-skill-bar .gc-skill-bar-glyph { - font-family: var(--fg-display); - font-size: var(--gc-skill-bar-glyph-size); - color: var(--fg-gold-bright); - line-height: 1; -} - -gc-skill-bar .gc-skill-bar-cell.is-disabled .gc-skill-bar-glyph { - color: var(--fg-parch-dim); -} - -gc-skill-bar .gc-skill-bar-hotkey { - position: absolute; - top: 2px; - left: 3px; - font-family: var(--fg-mono); - font-size: 10px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; - letter-spacing: 0.06em; -} - -gc-skill-bar .gc-skill-bar-charges { - position: absolute; - bottom: 2px; - right: 3px; - font-family: var(--fg-mono); - font-size: 11px; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000, 0 0 3px #000; - letter-spacing: 0.04em; -} - -gc-skill-bar .gc-skill-bar-cooldown { - position: absolute; - inset: 0; - pointer-events: none; - background: conic-gradient(from -90deg, rgba(0, 0, 0, 0.75) var(--cd, 0deg), transparent 0); - display: flex; - align-items: center; - justify-content: center; -} - -gc-skill-bar .gc-skill-bar-cooldown-label { - font-family: var(--fg-mono); - font-size: 14px; - color: #fff; - text-shadow: 0 1px 2px #000, 0 0 4px #000; - letter-spacing: 0.04em; -} - -gc-skill-bar .gc-skill-bar-cell:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-skill-bar .gc-skill-bar-cell:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_skill-tree.scss b/game-components/style/components/_skill-tree.scss deleted file mode 100644 index 69cb29dd..00000000 --- a/game-components/style/components/_skill-tree.scss +++ /dev/null @@ -1,133 +0,0 @@ -gc-skill-tree { - display: inline-flex; - flex-direction: column; - gap: 8px; - box-sizing: border-box; - color: var(--fg-parch); -} - -gc-skill-tree .gc-skill-tree-points { - display: flex; - align-items: baseline; - gap: 8px; - padding: 6px 10px; - background: rgba(0, 0, 0, 0.4); - border: 1px solid var(--fg-gold-deep); - align-self: flex-start; -} - -gc-skill-tree .gc-skill-tree-points-value { - font-family: var(--fg-mono); - font-size: 14px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} - -gc-skill-tree .gc-skill-tree-canvas { - position: relative; - box-sizing: border-box; - background: - radial-gradient(60% 50% at 50% 30%, #4a3a22 0%, #1a1108 60%, #0a0604 100%), - linear-gradient(180deg, #1a1308 0%, #0a0604 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-skill-tree .gc-skill-tree-edges { - position: absolute; - top: 0; - left: 0; - pointer-events: none; -} - -gc-skill-tree .gc-skill-tree-edge { - stroke: var(--fg-gold-shadow); - stroke-width: 2; - stroke-dasharray: 4 4; -} - -gc-skill-tree .gc-skill-tree-edge.is-unlocked { - stroke: var(--fg-gold-bright); - stroke-dasharray: none; -} - -gc-skill-tree .gc-skill-tree-node { - position: absolute; - transform: translate(-50%, -50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 3px; - cursor: pointer; - user-select: none; - z-index: 1; -} - -gc-skill-tree .gc-skill-tree-glyph { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--fg-display); - font-size: 16px; - color: var(--fg-parch-3); - background: radial-gradient(80% 80% at 50% 30%, #2a1f14, #0e0905); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.18), - inset 0 -2px 6px rgba(0, 0, 0, 0.6); - text-shadow: 0 1px 2px #000; - transform: rotate(45deg); - transition: transform 0.12s ease, filter 0.12s ease; -} - -gc-skill-tree .gc-skill-tree-glyph::before { - transform: rotate(-45deg); - display: inline-block; -} - -gc-skill-tree .gc-skill-tree-node.is-unlocked .gc-skill-tree-glyph { - color: var(--fg-gold-bright); - border-color: var(--fg-gold); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 0 rgba(232, 200, 120, 0.25), - 0 0 12px rgba(232, 200, 120, 0.4); -} - -gc-skill-tree .gc-skill-tree-node.is-locked { - cursor: not-allowed; - opacity: 0.55; -} - -gc-skill-tree .gc-skill-tree-node.is-selected .gc-skill-tree-glyph { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-skill-tree .gc-skill-tree-node:focus-visible .gc-skill-tree-glyph { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-skill-tree .gc-skill-tree-label { - font-family: var(--fg-display); - font-size: 9px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000; - margin-top: 6px; -} - -gc-skill-tree .gc-skill-tree-rank { - font-family: var(--fg-mono); - font-size: 9px; - letter-spacing: 0.16em; - color: var(--fg-gold-bright); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/_speedometer.scss b/game-components/style/components/_speedometer.scss deleted file mode 100644 index de7c88e6..00000000 --- a/game-components/style/components/_speedometer.scss +++ /dev/null @@ -1,103 +0,0 @@ -gc-speedometer { - --gc-speedo-size: 160px; - display: inline-block; - position: relative; - box-sizing: border-box; - width: var(--gc-speedo-size); - height: calc(var(--gc-speedo-size) * 0.7); - color: var(--fg-parch); -} - -gc-speedometer .gc-speedometer-svg { - display: block; - width: 100%; - height: 100%; - overflow: visible; -} - -gc-speedometer .gc-speedometer-track { - fill: none; - stroke: var(--fg-gold-shadow); - stroke-linecap: square; - opacity: 0.85; - filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.6)); -} - -gc-speedometer .gc-speedometer-fill { - fill: none; - stroke: var(--fg-gold-bright); - stroke-linecap: square; - filter: drop-shadow(0 0 4px rgba(232, 200, 120, 0.45)); - transition: stroke 0.2s ease, d 0.4s ease-out; -} - -gc-speedometer[data-danger="true"] .gc-speedometer-fill { - stroke: var(--fg-blood-bright); - filter: drop-shadow(0 0 6px rgba(212, 74, 58, 0.55)); -} - -gc-speedometer .gc-speedometer-tick { - stroke: var(--fg-gold); - stroke-width: 0.6; - opacity: 0.7; -} - -gc-speedometer[data-danger="true"] .gc-speedometer-tick:nth-last-child(-n+2) { - stroke: var(--fg-blood-bright); - opacity: 1; -} - -gc-speedometer .gc-speedometer-readout { - position: absolute; - left: 50%; - bottom: 6%; - transform: translateX(-50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - text-align: center; - pointer-events: none; -} - -gc-speedometer .gc-speedometer-gear { - font-family: var(--fg-display); - font-weight: 600; - font-size: calc(var(--gc-speedo-size) * 0.09); - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - line-height: 1; -} - -gc-speedometer .gc-speedometer-value { - font-family: var(--fg-mono); - font-weight: 700; - font-size: calc(var(--gc-speedo-size) * 0.22); - color: var(--fg-parch); - line-height: 1; - text-shadow: 0 1px 2px #000, 0 0 6px rgba(0, 0, 0, 0.6); -} - -gc-speedometer[data-danger="true"] .gc-speedometer-value { - color: var(--fg-blood-bright); - text-shadow: 0 1px 2px #000, 0 0 8px rgba(212, 74, 58, 0.5); -} - -gc-speedometer .gc-speedometer-unit { - font-family: var(--fg-display); - font-size: calc(var(--gc-speedo-size) * 0.07); - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-parch-dim); - line-height: 1; -} - -gc-speedometer .gc-speedometer-rpm { - font-family: var(--fg-mono); - font-size: calc(var(--gc-speedo-size) * 0.07); - letter-spacing: 0.16em; - color: var(--fg-gold); - line-height: 1; - margin-top: 2px; -} diff --git a/game-components/style/components/_stack.scss b/game-components/style/components/_stack.scss deleted file mode 100644 index 8cdbe614..00000000 --- a/game-components/style/components/_stack.scss +++ /dev/null @@ -1,7 +0,0 @@ -// ── Stack ────────────────────────────────────────────────────────────────────── - -gc-stack { - box-sizing: border-box; - min-width: 0; - min-height: 0; -} diff --git a/game-components/style/components/_stat-row.scss b/game-components/style/components/_stat-row.scss deleted file mode 100644 index 885ce08e..00000000 --- a/game-components/style/components/_stat-row.scss +++ /dev/null @@ -1,38 +0,0 @@ -gc-stat-row { - display: flex; - align-items: baseline; - gap: 12px; - padding: 6px 0; - box-sizing: border-box; - border-bottom: 1px solid rgba(201, 169, 97, 0.2); -} - -gc-stat-row .gc-stat-row-label { - flex: 1 1 auto; - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--fg-parch-3); -} - -gc-stat-row .gc-stat-row-value { - font-family: var(--fg-mono); - font-size: 14px; - color: var(--gc-stat-row-accent, var(--fg-gold-bright)); - text-shadow: 0 1px 2px #000; -} - -gc-stat-row .gc-stat-row-trend { - font-family: var(--fg-mono); - font-size: 11px; - letter-spacing: 0.08em; -} - -gc-stat-row .gc-stat-row-trend.is-up { - color: var(--fg-stamina-bright); -} - -gc-stat-row .gc-stat-row-trend.is-down { - color: var(--fg-blood-bright); -} diff --git a/game-components/style/components/_stats-screen.scss b/game-components/style/components/_stats-screen.scss deleted file mode 100644 index bc6db160..00000000 --- a/game-components/style/components/_stats-screen.scss +++ /dev/null @@ -1,126 +0,0 @@ -gc-stats-screen { - display: block; - box-sizing: border-box; - width: 100%; - padding: 36px 24px; - color: var(--fg-parch); - background: - radial-gradient(120% 90% at 50% 0%, #1f180e 0%, #0a0604 80%); -} - -gc-stats-screen .gc-stats-screen-root { - max-width: 880px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: 24px; -} - -gc-stats-screen .gc-stats-screen-header { - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; - text-align: center; -} - -gc-stats-screen .gc-stats-screen-eyebrow { - display: block; - color: var(--fg-parch-dim); -} - -gc-stats-screen .gc-stats-screen-title { - display: block; - --gc-title-size: 30px; - color: var(--fg-gold-bright); - text-shadow: 0 1px 0 #000, 0 0 18px rgba(232, 200, 120, 0.35); -} - -gc-stats-screen .gc-stats-screen-divider { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - width: min(420px, 80%); -} - -gc-stats-screen .gc-stats-screen-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-stats-screen .gc-stats-screen-diamond { - display: inline-block; - width: 8px; - height: 8px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); -} - -gc-stats-screen .gc-stats-screen-summary { - font-family: var(--fg-body); - font-style: italic; - font-size: 14px; - line-height: 1.5; - color: var(--fg-parch-3); - max-width: 560px; -} - -gc-stats-screen .gc-stats-screen-sections { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 16px; -} - -gc-stats-screen .gc-stats-screen-section { - display: flex; - flex-direction: column; - gap: 8px; - padding: 14px 16px; - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); -} - -gc-stats-screen .gc-stats-screen-section-title { - display: block; - color: var(--fg-gold-bright); - margin-bottom: 4px; -} - -gc-stats-screen .gc-stats-screen-section-stats { - display: flex; - flex-direction: column; - gap: 4px; -} - -gc-stats-screen .gc-stats-screen-stat { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 4px 0; - border-bottom: 1px solid rgba(201, 169, 97, 0.15); -} - -gc-stats-screen .gc-stats-screen-stat:last-child { - border-bottom: 0; -} - -gc-stats-screen .gc-stats-screen-stat-label { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-dim); -} - -gc-stats-screen .gc-stats-screen-stat-value { - font-family: var(--fg-mono); - font-size: 13px; - color: var(--fg-parch); -} diff --git a/game-components/style/components/_subtitle.scss b/game-components/style/components/_subtitle.scss deleted file mode 100644 index 4588c6f8..00000000 --- a/game-components/style/components/_subtitle.scss +++ /dev/null @@ -1,62 +0,0 @@ -gc-subtitle { - display: block; - box-sizing: border-box; - width: 100%; - max-width: var(--gc-subtitle-max-width, 560px); - margin-left: auto; - margin-right: auto; - text-align: center; - font-family: var(--fg-body); -} - -gc-subtitle:not([text]) { - display: none; -} - -gc-subtitle[align="left"] { - text-align: left; - margin-left: 0; - margin-right: auto; -} - -gc-subtitle[align="right"] { - text-align: right; - margin-left: auto; - margin-right: 0; -} - -gc-subtitle[align="center"] { - text-align: center; -} - -gc-subtitle .gc-subtitle-speaker { - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.32em; - text-transform: uppercase; - color: var(--fg-gold-bright); - font-weight: 500; - margin-bottom: 4px; -} - -gc-subtitle .gc-subtitle-text { - font-family: var(--fg-body); - font-size: var(--gc-subtitle-font-size, 18px); - line-height: 1.5; - color: var(--fg-parch); - font-style: italic; - text-shadow: 0 1px 0 #000, 0 0 8px #000, 0 0 16px #000; -} - -gc-subtitle[boxed] { - background: linear-gradient(180deg, #1a1308 0%, #0e0905 100%); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(232, 200, 120, 0.12); - padding: 14px 20px; -} - -gc-subtitle[boxed] .gc-subtitle-text { - text-shadow: none; -} diff --git a/game-components/style/components/_tab-bar.scss b/game-components/style/components/_tab-bar.scss deleted file mode 100644 index 951d5541..00000000 --- a/game-components/style/components/_tab-bar.scss +++ /dev/null @@ -1,58 +0,0 @@ -gc-tab-bar { - display: flex; - flex-wrap: wrap; - gap: 2px; - border-bottom: 1px solid rgba(201, 169, 97, 0.25); -} - -gc-tab-bar .gc-tab-bar-tab { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - font-family: var(--fg-display); - font-size: 11px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch-3); - background: transparent; - border: 0; - border-bottom: 2px solid transparent; - cursor: pointer; - user-select: none; - transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; -} - -gc-tab-bar[size="sm"] .gc-tab-bar-tab { - padding: 6px 12px; - font-size: 10px; -} - -gc-tab-bar .gc-tab-bar-tab:hover { - color: var(--fg-parch); -} - -gc-tab-bar .gc-tab-bar-tab.is-active { - color: var(--fg-gold-bright); - background: rgba(201, 169, 97, 0.12); - border-bottom-color: var(--fg-gold-bright); -} - -gc-tab-bar .gc-tab-bar-icon { - color: var(--fg-gold); - font-size: 12px; - line-height: 1; -} - -gc-tab-bar .gc-tab-bar-tab.is-active .gc-tab-bar-icon { - color: var(--fg-gold-bright); -} - -gc-tab-bar .gc-tab-bar-tab:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: -2px; -} - -gc-tab-bar .gc-tab-bar-tab:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_title-screen.scss b/game-components/style/components/_title-screen.scss deleted file mode 100644 index 94be7f04..00000000 --- a/game-components/style/components/_title-screen.scss +++ /dev/null @@ -1,64 +0,0 @@ -gc-title-screen { - display: block; - box-sizing: border-box; - width: 100%; - padding: 60px 32px; - text-align: center; - color: var(--fg-parch); - background: - radial-gradient(60% 50% at 50% 30%, #4a3a22 0%, #1a1108 60%, #0a0604 100%), - linear-gradient(180deg, #1a1308 0%, #0a0604 100%); -} - -gc-title-screen .gc-title-screen-root { - display: flex; - flex-direction: column; - align-items: center; - gap: 18px; -} - -gc-title-screen .gc-title-screen-eyebrow { - display: block; - color: var(--fg-parch-dim); - letter-spacing: 0.5em; - font-size: 12px; -} - -gc-title-screen .gc-title-screen-title { - display: block; - --gc-title-size: 44px; - color: var(--fg-gold-bright); - text-shadow: 0 2px 0 #000, 0 0 24px rgba(232, 200, 120, 0.35); -} - -gc-title-screen .gc-title-screen-divider { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - width: min(420px, 80%); -} - -gc-title-screen .gc-title-screen-rule { - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, transparent, var(--fg-gold-deep) 30%, var(--fg-gold) 50%, var(--fg-gold-deep) 70%, transparent); -} - -gc-title-screen .gc-title-screen-diamond { - display: inline-block; - width: 8px; - height: 8px; - background: linear-gradient(135deg, var(--fg-gold-bright), var(--fg-gold-deep)); - transform: rotate(45deg); - box-shadow: 0 0 6px rgba(232, 200, 120, 0.4); -} - -gc-title-screen .gc-title-screen-subtitle { - font-family: var(--fg-body); - font-style: italic; - font-size: 14px; - line-height: 1.5; - color: var(--fg-parch-3); - max-width: 520px; -} diff --git a/game-components/style/components/_title.scss b/game-components/style/components/_title.scss deleted file mode 100644 index 01cd39f9..00000000 --- a/game-components/style/components/_title.scss +++ /dev/null @@ -1,10 +0,0 @@ -gc-title { - display: block; - font-family: var(--fg-display); - font-weight: 600; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-gold-bright); - font-size: var(--gc-title-size, 18px); - line-height: 1.2; -} diff --git a/game-components/style/components/_toggle.scss b/game-components/style/components/_toggle.scss deleted file mode 100644 index 7097023d..00000000 --- a/game-components/style/components/_toggle.scss +++ /dev/null @@ -1,55 +0,0 @@ -gc-toggle { - display: inline-block; - cursor: pointer; - user-select: none; - line-height: 0; -} - -gc-toggle[disabled] { - cursor: not-allowed; - opacity: 0.45; -} - -gc-toggle .gc-toggle-track { - position: relative; - display: inline-block; - width: 44px; - height: 22px; - background: linear-gradient(180deg, #0e0905, #1a120a); - border: 1px solid var(--fg-gold-deep); - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.7), - inset 0 1px 3px rgba(0, 0, 0, 0.8); - box-sizing: border-box; - transition: background 0.15s ease; -} - -gc-toggle .gc-toggle-knob { - position: absolute; - top: 1px; - left: 1px; - width: 18px; - height: 18px; - background: linear-gradient(180deg, var(--fg-gold-bright), var(--fg-gold-deep)); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.3), - 0 1px 2px rgba(0, 0, 0, 0.6); - transition: left 0.15s ease, background 0.15s ease; -} - -gc-toggle[on] .gc-toggle-track { - background: linear-gradient(180deg, #2a1a08, #4a2a0e); -} - -gc-toggle[on] .gc-toggle-knob { - left: 23px; -} - -gc-toggle:focus-visible { - outline: 2px solid var(--fg-gold-bright); - outline-offset: 2px; -} - -gc-toggle:focus:not(:focus-visible) { - outline: none; -} diff --git a/game-components/style/components/_transition-wipe.scss b/game-components/style/components/_transition-wipe.scss deleted file mode 100644 index 236eeb75..00000000 --- a/game-components/style/components/_transition-wipe.scss +++ /dev/null @@ -1,70 +0,0 @@ -gc-transition-wipe { - position: fixed; - inset: 0; - pointer-events: none; - z-index: 1085; - display: block; -} - -gc-transition-wipe[show] { - pointer-events: auto; -} - -gc-transition-wipe .gc-transition-wipe-fill { - --gc-transition-wipe-duration: 400ms; - position: absolute; - inset: 0; - background: #0a0604; - transition: - opacity var(--gc-transition-wipe-duration) ease-in-out, - transform var(--gc-transition-wipe-duration) ease-in-out, - clip-path var(--gc-transition-wipe-duration) ease-in-out; -} - -gc-transition-wipe .gc-transition-wipe-fill[data-direction="fade"] { - opacity: 0; -} - -gc-transition-wipe[show] .gc-transition-wipe-fill[data-direction="fade"] { - opacity: 1; -} - -gc-transition-wipe .gc-transition-wipe-fill[data-direction="left"] { - transform: translateX(-100%); -} - -gc-transition-wipe[show] .gc-transition-wipe-fill[data-direction="left"] { - transform: translateX(0); -} - -gc-transition-wipe .gc-transition-wipe-fill[data-direction="right"] { - transform: translateX(100%); -} - -gc-transition-wipe[show] .gc-transition-wipe-fill[data-direction="right"] { - transform: translateX(0); -} - -gc-transition-wipe .gc-transition-wipe-fill[data-direction="up"] { - transform: translateY(-100%); -} - -gc-transition-wipe[show] .gc-transition-wipe-fill[data-direction="up"] { - transform: translateY(0); -} - -gc-transition-wipe .gc-transition-wipe-fill[data-direction="down"] { - transform: translateY(100%); -} - -gc-transition-wipe[show] .gc-transition-wipe-fill[data-direction="down"] { - transform: translateY(0); -} - -gc-transition-wipe .gc-transition-wipe-fill[data-direction="iris"] { - clip-path: circle(150% at 50% 50%); -} - -gc-transition-wipe[show] .gc-transition-wipe-fill[data-direction="iris"] { - clip-path: circle(0% at 50% 50%); -} diff --git a/game-components/style/components/_version-label.scss b/game-components/style/components/_version-label.scss deleted file mode 100644 index df4708c1..00000000 --- a/game-components/style/components/_version-label.scss +++ /dev/null @@ -1,27 +0,0 @@ -gc-version-label { - display: inline-flex; - align-items: center; - font-family: var(--fg-mono); - font-size: 10px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--fg-parch-3); - line-height: 1; -} - -gc-version-label .gc-version-label-version { - color: var(--fg-parch); -} - -gc-version-label .gc-version-label-build { - color: var(--fg-parch-3); -} - -gc-version-label .gc-version-label-branch { - color: var(--fg-gold); -} - -gc-version-label .gc-version-label-sep { - color: var(--fg-gold-deep); - margin: 0 6px; -} diff --git a/game-components/style/components/_vignette-overlay.scss b/game-components/style/components/_vignette-overlay.scss deleted file mode 100644 index acf319d3..00000000 --- a/game-components/style/components/_vignette-overlay.scss +++ /dev/null @@ -1,12 +0,0 @@ -gc-vignette-overlay { - position: absolute; - inset: 0; - pointer-events: none; - background: radial-gradient( - ellipse at center, - transparent 50%, - var(--gc-vignette-color, #000000) 130% - ); - opacity: var(--gc-vignette-intensity, 0.6); - z-index: 1030; -} diff --git a/game-components/style/components/_waypoint-marker.scss b/game-components/style/components/_waypoint-marker.scss deleted file mode 100644 index 8d2163a1..00000000 --- a/game-components/style/components/_waypoint-marker.scss +++ /dev/null @@ -1,47 +0,0 @@ -gc-waypoint-marker { - position: absolute; - display: inline-flex; - flex-direction: column; - align-items: center; - gap: 4px; - pointer-events: none; - transform: translate(-50%, -100%); - color: var(--gc-waypoint-marker-color, var(--fg-parch)); - font-family: var(--fg-display); -} - -gc-waypoint-marker .gc-waypoint-marker-glyph { - font-size: var(--gc-waypoint-marker-size, 18px); - line-height: 1; - color: var(--gc-waypoint-marker-color, var(--fg-gold-bright)); - text-shadow: - 0 1px 2px #000, - 0 0 8px var(--gc-waypoint-marker-color, rgba(232, 200, 120, 0.4)); -} - -gc-waypoint-marker .gc-waypoint-marker-text { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - background: rgba(10, 6, 4, 0.65); - padding: 2px 6px; - border: 1px solid var(--fg-gold-deep); -} - -gc-waypoint-marker .gc-waypoint-marker-label { - font-family: var(--fg-display); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--fg-parch); - text-shadow: 0 1px 2px #000; -} - -gc-waypoint-marker .gc-waypoint-marker-distance { - font-family: var(--fg-mono); - font-size: 9px; - letter-spacing: 0.16em; - color: var(--fg-parch-3); - text-shadow: 0 1px 2px #000; -} diff --git a/game-components/style/components/index.scss b/game-components/style/components/index.scss deleted file mode 100644 index 686e0cd6..00000000 --- a/game-components/style/components/index.scss +++ /dev/null @@ -1,121 +0,0 @@ -@use './stack'; -@use './grid'; -@use './anchor'; -@use './panel'; -@use './gilded-frame'; -@use './artboard-backdrop'; -@use './title'; -@use './subtitle'; -@use './eyebrow'; -@use './key'; -@use './lore-text'; -@use './scroll-text'; -@use './version-label'; -@use './icon-badge'; -@use './rarity-chip'; -@use './currency-chip'; -@use './currency-display'; -@use './portrait'; -@use './platform-icon'; -@use './gamepad-button-prompt'; -@use './compass-rose'; -@use './rune-corner'; -@use './ping-display'; -@use './page-indicator'; -@use './metal-button'; -@use './nav-button'; -@use './menu-item'; -@use './list-row'; -@use './circular-progress'; -@use './cooldown-badge'; -@use './hit-marker'; -@use './damage-number'; -@use './network-status-icon'; -@use './combo-counter'; -@use './score-display'; -@use './screen-flash'; -@use './shake-container'; -@use './transition-wipe'; -@use './interact-prompt'; -@use './resource-bar'; -@use './ammo-counter'; -@use './boss-bar'; -@use './setting-rows'; -@use './buff'; -@use './crosshair'; -@use './speedometer'; -@use './brightness-calibration'; -@use './particle-emitter'; -@use './safe-area'; -@use './aspect-ratio-box'; -@use './tab-bar'; -@use './main-menu'; -@use './list'; -@use './settings-category-list'; -@use './pause-menu'; -@use './combo-box'; -@use './dialogue-box'; -@use './report-player-dialog'; -@use './invite-toast'; -@use './legal-screen'; -@use './character-create'; -@use './character-select'; -@use './player-card'; -@use './player-frame'; -@use './title-screen'; -@use './loading-screen'; -@use './pause-screen'; -@use './result-screen'; -@use './stats-screen'; -@use './matchmaking-screen'; -@use './toggle'; -@use './check'; -@use './key-binder'; -@use './vignette-overlay'; -@use './blur-overlay'; -@use './letterbox-bars'; -@use './divider'; -@use './confirm-dialog'; -@use './loading-overlay'; -@use './debug-overlay'; -@use './controls-rebind-list'; -@use './item-slot'; -@use './item-tooltip'; -@use './item-compare'; -@use './hotbar'; -@use './inventory-grid'; -@use './equipment-doll'; -@use './ability-card'; -@use './skill-bar'; -@use './compass-bar'; -@use './minimap'; -@use './objective-marker'; -@use './waypoint-marker'; -@use './chat-window'; -@use './friends-list'; -@use './mute-list'; -@use './kill-feed'; -@use './level-header'; -@use './level-select'; -@use './skill-tree'; -@use './codex'; -@use './journal'; -@use './quest-tracker'; -@use './achievement-list'; -@use './battle-pass'; -@use './crafting-panel'; -@use './shop-panel'; -@use './loot-list'; -@use './loot-popup'; -@use './guild-panel'; -@use './party-panel'; -@use './lobby'; -@use './perk-picker'; -@use './radial-wheel'; -@use './save-slot-list'; -@use './controller-layout-preview'; -@use './credits-list'; -@use './credits-scroll'; -@use './press-any-key'; -@use './stat-row'; -@use './panel-header'; diff --git a/game-components/style/index.scss b/game-components/style/index.scss deleted file mode 100644 index 66afe3d9..00000000 --- a/game-components/style/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@use './layouts'; -@use './components'; -@use './themes'; diff --git a/game-components/style/layouts/index.scss b/game-components/style/layouts/index.scss deleted file mode 100644 index 1e0de1bb..00000000 --- a/game-components/style/layouts/index.scss +++ /dev/null @@ -1 +0,0 @@ -// Layout styles diff --git a/game-components/style/themes/_dark.scss b/game-components/style/themes/_dark.scss deleted file mode 100644 index 687518e0..00000000 --- a/game-components/style/themes/_dark.scss +++ /dev/null @@ -1,3 +0,0 @@ -:root { - color-scheme: dark; -} diff --git a/game-components/style/themes/index.scss b/game-components/style/themes/index.scss deleted file mode 100644 index 5af837a2..00000000 --- a/game-components/style/themes/index.scss +++ /dev/null @@ -1 +0,0 @@ -@use './dark'; diff --git a/game-components/tsup.config.js b/game-components/tsup.config.js deleted file mode 100644 index 67809ff3..00000000 --- a/game-components/tsup.config.js +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['cjs', 'esm'], - dts: true, - clean: true, - outDir: 'lib', - external: ['@toolcase/base'], - outExtension({ format }) { - return { - js: format === 'esm' ? '.module.js' : '.main.js', - } - }, -}) diff --git a/logging/README.md b/logging/README.md index 5a586ee4..1ce262eb 100644 --- a/logging/README.md +++ b/logging/README.md @@ -55,6 +55,15 @@ dbLog.setLevel('debug') // 'db' logs debug; everything else uses factory defau dbLog.setLevel(null) // remove override ``` +Pattern-based overrides via `setLevel(pattern, level)` on the factory: + +```ts +logging.setLevel('db:*', 'debug') // any scope matching db:* → debug +logging.setLevel('db:pool', 'info') // most-specific pattern wins +``` + +`*` matches any character sequence including `:`. The most specific pattern (fewest wildcards) wins when multiple match; last-registered wins on a tie. Per-logger `setLevel()` always beats pattern overrides. + ## Scoped loggers Each call to `getLogger(scope)` returns a stable logger for that scope — same name, same instance. @@ -67,6 +76,14 @@ apiLog.info('GET /users') // [api] GET /users dbLog.info('SELECT … ') // [db] SELECT … ``` +Child loggers combine scopes with `:`: + +```ts +const db = logging.getLogger('db') +const pool = db.child('pool') // scope: db:pool +pool.info('connected') // [db:pool] connected +``` + ## Structured context `Logger.withContext(obj)` returns a new logger that prepends a context object to every log call. Useful for request/correlation IDs. @@ -81,16 +98,86 @@ function handle(req) { } ``` +## Lazy thunk arguments + +Any argument that is a function is called only when the level is enabled; its return value is logged instead. Useful for expensive serialization. + +```ts +log.debug(() => JSON.stringify(bigObject)) +log.debug('prefix', () => computeExpensiveDiff(), 'suffix') +``` + +Or gate explicitly with `isEnabled`: + +```ts +if (log.isEnabled('debug')) { + log.debug('diff', computeExpensiveDiff()) +} +``` + +## Env-driven configuration + +`parseEnv(env)` reads `LOG_LEVEL` and `DEBUG` from any string record: + +```ts +// Node +logging.parseEnv(process.env) + +// Vite / ESM +logging.parseEnv(import.meta.env) +``` + +`LOG_LEVEL` sets the global factory level (silently ignored if unknown). `DEBUG` is a comma-or-space-separated list of scope patterns; each becomes `setLevel(pattern, 'debug')`. + ## Reporters -A reporter receives every log line and decides what to do with it (print, ship to a server, write to a file, batch and flush, etc.). Built-ins: +A reporter receives every log line and decides what to do with it. Built-ins: | Reporter | Where it works | What it does | |----------|---------------|--------------| -| `ConsoleLogReporter` | Browser + Node | Pretty-prints to the developer console. Default. | +| `ConsoleLogReporter` | Browser + Node | Pretty-prints to the console with colored output. Default. | | `JSONLineReporter` | Browser + Node | Emits one JSON object per line. Good for log aggregators. | -| `BufferedReporter` | Browser + Node | Wraps any reporter (or an `onFlush` handler) and flushes in batches. | -| `FileLogReporter` | **Node only** | Writes to disk with optional rotation. Imported from `@toolcase/logging/node`. | +| `BufferedReporter` | Browser + Node | Batches entries and flushes on a timer or when the buffer is full. | +| `RingBufferReporter` | Browser + Node | Fixed-capacity ring; oldest entries evicted. Snapshot for crash dumps or overlays. | +| `HTTPReporter` | Browser + Node | POSTs batches to an HTTP endpoint with automatic retry. | +| `OTLPReporter` | Browser + Node | POSTs batches to an OTLP HTTP endpoint in OpenTelemetry format. | +| `MemoryReporter` | Browser + Node | In-memory store designed for unit tests. Never drains on read. | +| `LevelFilterReporter` | Browser + Node | Decorator: forwards only entries at or above a minimum level. | +| `ScopeFilterReporter` | Browser + Node | Decorator: forwards only entries whose scope matches a glob. | +| `RedactionReporter` | Browser + Node | Decorator: replaces sensitive keys with `'[REDACTED]'` before forwarding. | +| `SamplingReporter` | Browser + Node | Decorator: forwards a random fraction of entries. | +| `FanoutReporter` | Browser + Node | Broadcasts to a list of inner reporters. `MultiReporter` is an alias. | +| `StreamReporter` | **Node only** | Writes to any `node:stream.Writable`. Imported from `@toolcase/logging/node`. | +| `FileLogReporter` | **Node only** | Writes to a file path with size-based rotation. Imported from `@toolcase/logging/node`. | +| `ContextualReporter` | **Node only** | Decorator: merges `AsyncLocalStorage` context into every entry. Imported from `@toolcase/logging/node`. | +| `BeaconReporter` | **Browser only** | Buffers entries and flushes via `navigator.sendBeacon`. Imported from `@toolcase/logging/browser`. | +| `IndexedDBReporter` | **Browser only** | Persists entries to IndexedDB; survives page reloads. Imported from `@toolcase/logging/browser`. | + +### ConsoleLogReporter options + +```ts +new ConsoleLogReporter(options?: ConsoleLogReporterOptions) +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `color` | `boolean` | auto | Force color on or off. Auto-enables on Node TTYs and in the browser; auto-disables under `NO_COLOR` or non-TTY Node output. | +| `timestamp` | `boolean` | `true` | Include the ISO timestamp in the default prefix. | +| `prefix` | `string \| (level, scope, time) => string` | — | Override the prefix. A string is used as-is; a function receives `(level, scope, time)` and must return the prefix string. When set, `timestamp` has no effect. | +| `objects` | `'compact' \| 'pretty'` | `'compact'` | How plain objects are formatted. `'compact'` passes values to `console` as-is (native expansion in devtools / `util.inspect` in Node). `'pretty'` serializes objects/arrays to indented JSON and errors to `name: message\nstack`. | + +```ts +new ConsoleLogReporter() // auto-detect color, default prefix, compact objects +new ConsoleLogReporter({ color: true }) // force color on +new ConsoleLogReporter({ color: false }) // force color off +new ConsoleLogReporter({ timestamp: false }) // omit timestamp from prefix +new ConsoleLogReporter({ objects: 'pretty' }) // serialize objects as indented JSON +new ConsoleLogReporter({ + prefix: (level, scope) => `[${scope}] ${level.toUpperCase()}` +}) +``` + +`NO_COLOR` (any value) in the environment disables color auto-detection — the reporter will not emit ANSI codes or `%c` browser styles. `{ color: true }` still overrides this. ### Multiple reporters @@ -129,6 +216,99 @@ const reporter = new BufferedReporter(null, { }) ``` +Both can be supplied at once — `onFlush` fires first (useful as a batch hook or for shipping the raw array), then every entry is forwarded individually to `inner`: + +```ts +const reporter = new BufferedReporter( + new ConsoleLogReporter(), // sink: receives each entry one-by-one + { + maxSize: 50, + flushInterval: 2000, + onFlush: entries => ship(entries) // hook: also ships the whole batch + } +) +``` + +### Ring buffer + +`RingBufferReporter` keeps the last `N` entries in memory. Oldest entries are silently evicted when full. Ideal for crash dumps and debug overlays. + +```ts +import { LoggerFactory, RingBufferReporter } from '@toolcase/logging' + +const ring = new RingBufferReporter(100) +const logging = new LoggerFactory([ring]) + +const snap = ring.snapshot() // LogEntry[], oldest → newest +ring.clear() // reset; capacity preserved +``` + +### HTTP reporter + +`HTTPReporter` buffers entries internally and POSTs each batch as JSON to a URL. Retries on network errors or non-2xx responses with exponential back-off. Works in Node 18+ and modern browsers. + +```ts +import { LoggerFactory, HTTPReporter } from '@toolcase/logging' + +const reporter = new HTTPReporter({ + url: 'https://ingest.example.com/logs', + headers: { Authorization: 'Bearer my-token' }, + maxSize: 100, + flushInterval: 5000, + retries: 3, +}) + +const logging = new LoggerFactory([reporter]) + +// On shutdown: +reporter.close() +``` + +Each POST body is `{ "entries": LogEntry[] }`. Inject a custom `transport` for testing: + +```ts +import { HTTPReporter, type HTTPTransport } from '@toolcase/logging' + +const transport: HTTPTransport = async (url, body, headers) => { + const res = await myFetch(url, { method: 'POST', body, headers }) + return res.status +} +const reporter = new HTTPReporter({ url: '...', transport }) +``` + +### OTLP reporter + +`OTLPReporter` sends batches to an OpenTelemetry-compatible HTTP collector as `LogsData` JSON. Bridges `LoggerLevel` to OTLP severity numbers. + +```ts +import { LoggerFactory, OTLPReporter } from '@toolcase/logging' + +const reporter = new OTLPReporter({ + url: 'https://otel-collector.example.com/v1/logs', + resource: { 'service.name': 'api', 'deployment.environment': 'prod' }, + maxSize: 100, + flushInterval: 5000, +}) + +const logging = new LoggerFactory([reporter]) +reporter.close() // on shutdown +``` + +### Stream reporter (Node) + +`StreamReporter` writes formatted log lines to any Node.js `Writable` stream — stdout, a TCP socket, a `PassThrough`, or any custom writable. + +```ts +import { StreamReporter } from '@toolcase/logging/node' + +const reporter = new StreamReporter(process.stdout, { + formatter: (level, scope, time, _fields, messages) => + `${level} ${scope} ${messages.join(' ')}`, + onError: err => console.error('stream error', err), + maxBytes: 10 * 1024 * 1024, // resets byte counter; no rotation on generic streams +}) +``` + ### File reporter (Node) ```ts @@ -136,16 +316,197 @@ import { LoggerFactory } from '@toolcase/logging' import { FileLogReporter } from '@toolcase/logging/node' const logging = new LoggerFactory([ - new FileLogReporter({ filepath: './logs/app.log' }) + new FileLogReporter('./logs/app.log') ]) ``` +Size-based rotation is supported via `maxBytes` and `maxFiles`. When the active file reaches `maxBytes`, it is renamed to `.1` (shifting older files up to `.maxFiles`, dropping the oldest) and a fresh file is opened: + +```ts +new FileLogReporter('./logs/app.log', { + maxBytes: 10 * 1024 * 1024, // rotate at 10 MB + maxFiles: 5, // keep app.log.1 … app.log.5 +}) +``` + +### Composable wrapper reporters + +Five decorator reporters each wrap one inner `LogReporter` and forward `flush()` and `close()`. Compose freely with `FanoutReporter` to build per-sink pipelines. + +```ts +import { + LevelFilterReporter, ScopeFilterReporter, + RedactionReporter, SamplingReporter, FanoutReporter +} from '@toolcase/logging' +``` + +**`LevelFilterReporter`** — forwards only entries at or above `minLevel`: + +```ts +new LevelFilterReporter(new ConsoleLogReporter(), 'error') +``` + +**`ScopeFilterReporter`** — forwards only entries whose scope matches a glob pattern: + +```ts +new ScopeFilterReporter(auditSink, 'db:*') // 'db:pool', 'db:pool:worker', … +``` + +**`RedactionReporter`** — replaces matching key values with `'[REDACTED]'` (supports string array or `RegExp`): + +```ts +new RedactionReporter(jsonSink, ['password', 'token']) +new RedactionReporter(jsonSink, /password|secret|token/i) +``` + +**`SamplingReporter`** — forwards `rate` fraction of entries (0–1): + +```ts +new SamplingReporter(traceSink, 0.1) // forward ~10 % +``` + +**`FanoutReporter`** — broadcasts to a list of inner reporters (each isolated inside its own try/catch): + +```ts +const fanout = new FanoutReporter([ + new LevelFilterReporter(new ConsoleLogReporter(), 'error'), + new ScopeFilterReporter(auditSink, 'audit:*'), + new RedactionReporter(jsonSink, ['password', 'token']), + new SamplingReporter(traceSink, 0.05), +]) +const logging = new LoggerFactory([fanout]) +``` + +`MultiReporter` is an alias for `FanoutReporter`. + +### Memory reporter (testing) + +`MemoryReporter` accumulates entries without draining on read. Designed for unit tests. + +```ts +import { LoggerFactory, MemoryReporter } from '@toolcase/logging' + +const mem = new MemoryReporter() +const logging = new LoggerFactory([mem], () => 1_000_000) // fixed clock + +logging.getLogger('app').info('boot') +logging.getLogger('app').error('oops') + +mem.entries() // LogEntry[] — all entries, oldest → newest; never drained +mem.find('error') // LogEntry[] — filtered to exact level +mem.clear() // reset for the next test case +``` + +### Browser reporters (`@toolcase/logging/browser`) + +**`BeaconReporter`** — buffers entries and flushes via `navigator.sendBeacon` (fire-and-forget, survives page unload). Optionally captures global errors. + +```ts +import { BeaconReporter } from '@toolcase/logging/browser' +import { LoggerFactory } from '@toolcase/logging' + +const reporter = new BeaconReporter({ + url: 'https://telemetry.example.com/logs', + maxSize: 50, + captureErrors: true, // auto-capture window.onerror + unhandledrejection +}) +const logging = new LoggerFactory([reporter]) +``` + +**`IndexedDBReporter`** — persists entries to IndexedDB so they survive page reloads. Call `drain()` on next startup to retrieve and clear the stored entries. + +```ts +import { IndexedDBReporter } from '@toolcase/logging/browser' +import { LoggerFactory } from '@toolcase/logging' + +const reporter = new IndexedDBReporter({ maxEntries: 1000 }) +const logging = new LoggerFactory([reporter]) + +// On next startup: +const buffered = await reporter.drain() +await fetch('/logs', { method: 'POST', body: JSON.stringify(buffered) }) +``` + +### Async context (Node) + +`AsyncContext` wraps `AsyncLocalStorage` to propagate fields across async call chains. Pair it with `ContextualReporter` to attach per-request context to every log call automatically. + +```ts +import { AsyncContext, ContextualReporter } from '@toolcase/logging/node' +import { LoggerFactory, ConsoleLogReporter } from '@toolcase/logging' + +const ctx = new AsyncContext() +const logging = new LoggerFactory([ + new ContextualReporter(new ConsoleLogReporter(), ctx) +]) + +ctx.run({ requestId: 'r1' }, async () => { + await someAsyncWork() + logging.getLogger('api').info('handled') // fields includes requestId: 'r1' +}) +``` + +### Formatters + +Three named formatters are exported from `@toolcase/logging` and can be passed to any reporter that accepts a `formatter` option: + +| Export | Output shape | +|--------|-------------| +| `textFormatter` | `LEVEL [ISO-time] \| scope: msg …` — human-readable; default for `StreamReporter`/`FileLogReporter` | +| `jsonFormatter` | Single-line JSON — default for `JSONLineReporter` | +| `logfmtFormatter` | `level=… scope=… ts=… msg=…` key-value pairs | + +```ts +import { JSONLineReporter, logfmtFormatter } from '@toolcase/logging' +import { FileLogReporter } from '@toolcase/logging/node' + +const reporter = new JSONLineReporter({ formatter: logfmtFormatter }) +const file = new FileLogReporter('./app.log', { formatter: jsonFormatter }) +``` + +### Graceful shutdown + +`BufferedReporter` and `FileLogReporter` hold in-flight state that must be drained before the process exits. + +**`BufferedReporter`** — call `close()` on shutdown. It flushes the pending batch synchronously and cancels the interval timer. + +```ts +const remote = new BufferedReporter(inner, { flushInterval: 2000 }) + +process.on('SIGTERM', () => { + remote.close() + process.exit(0) +}) +``` + +As a safety net, `BufferedReporter` also registers a `process.once('beforeExit', …)` listener on Node.js that flushes any remaining entries automatically when the event loop drains. This does not replace an explicit `close()` call. + +**`FileLogReporter`** — call `await close()` on shutdown. It waits for any in-progress rotation to complete, then closes the underlying write stream. + +```ts +const file = new FileLogReporter('./logs/app.log') + +process.on('SIGTERM', async () => { + await file.close() + process.exit(0) +}) +``` + +Or call `logging.close()` to drain all reporters at once: + +```ts +process.on('SIGTERM', async () => { + await logging.close() + process.exit(0) +}) +``` + ### Custom reporter -Extend `LogReporter` and implement `log(level, scope, time, messages)`: +Extend `LogReporter` and implement `log(level, scope, time, fields, messages)`: ```ts -import { LogReporter } from '@toolcase/logging' +import { LoggerFactory, LogReporter } from '@toolcase/logging' class SentryReporter extends LogReporter { log(level, scope, time, messages) { @@ -155,7 +516,8 @@ class SentryReporter extends LogReporter { } } -logging.addReporter?.(new SentryReporter()) // or pass into the LoggerFactory ctor +const logging = new LoggerFactory() +logging.addReporter(new SentryReporter()) ``` ## API @@ -170,6 +532,12 @@ A pre-configured `LoggerFactory` with a `ConsoleLogReporter`. Suitable for quick |--------|------|-------------| | `getLogger(scope?)` | `(scope?: string) => Logger` | Get/create a scoped logger. | | `level` | `LoggerLevel` | Global threshold. | +| `setLevel(pattern, level)` | `(pattern: string, level: LoggerLevel) => void` | Override threshold for all scopes matching a glob pattern. Throws `RangeError` for unknown levels. | +| `parseEnv(env)` | `(env: Record) => void` | Read `LOG_LEVEL` and `DEBUG` from a string record (e.g. `process.env`). | +| `addReporter(reporter)` | `(reporter: LogReporter) => void` | Attach a reporter after construction. | +| `removeReporter(reporter)` | `(reporter: LogReporter) => void` | Detach a previously added reporter. | +| `flush()` | `() => void` | Call `flush()` on every attached reporter. Errors are isolated per reporter. | +| `close()` | `() => Promise` | Call `close()` on every attached reporter, awaiting any promises. Errors are isolated per reporter. | ### `Logger` @@ -180,9 +548,11 @@ A pre-configured `LoggerFactory` with a `ConsoleLogReporter`. Suitable for quick | `info(...args)` | Log at `info` level. | | `debug(...args)` | Log at `debug` level. | | `verbose(...args)` | Log at `verbose` level. | +| `isEnabled(level)` | Returns `true` if the level would be logged (respects factory threshold and overrides). | | `setLevel(level \| null)` | Per-logger override. | | `getLevel()` | Returns override or `null`. | | `withContext(obj)` | New logger that prepends `obj` to every message. | +| `child(scope)` | New logger whose scope is `parentScope:scope`, inheriting context and overrides. | ### `LoggerLevel` @@ -190,9 +560,11 @@ A pre-configured `LoggerFactory` with a `ConsoleLogReporter`. Suitable for quick 'silent' | 'error' | 'warning' | 'info' | 'debug' | 'verbose' ``` +Use `isKnownLevel(value)` and `KNOWN_LEVELS` (both exported from `@toolcase/logging`) to validate level strings at runtime. + ### `LogReporter` -Base class. Override `log(level, scope, time, messages)`. +Base class. Override `log(level, scope, time, fields, messages)`, and optionally `flush()` and `close()`. ## License diff --git a/logging/package.json b/logging/package.json index d7434df9..8f831a7c 100644 --- a/logging/package.json +++ b/logging/package.json @@ -1,7 +1,7 @@ { "name": "@toolcase/logging", - "version": "3.0.2", - "description": "Tiny isomorphic logger for Node.js and the browser — colored output, log levels, named reporters, zero dependencies.", + "version": "5.0.0", + "description": "Tiny isomorphic logger for Node.js and the browser — log levels, named reporters, zero dependencies.", "source": "src/main.js", "main": "lib/main.main.js", "module": "lib/main.module.js", @@ -16,6 +16,11 @@ "types": "./lib/node.d.ts", "import": "./lib/node.module.js", "require": "./lib/node.main.js" + }, + "./browser": { + "types": "./lib/browser.d.ts", + "import": "./lib/browser.module.js", + "require": "./lib/browser.main.js" } }, "sideEffects": false, diff --git a/logging/src/AsyncContext.ts b/logging/src/AsyncContext.ts new file mode 100644 index 00000000..0825844f --- /dev/null +++ b/logging/src/AsyncContext.ts @@ -0,0 +1,22 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +class AsyncContext { + + private storage: AsyncLocalStorage> + + constructor() { + this.storage = new AsyncLocalStorage() + } + + run(fields: Record, fn: () => T): T { + const parent = this.storage.getStore() ?? {} + return this.storage.run({ ...parent, ...fields }, fn) + } + + getFields(): Record { + return this.storage.getStore() ?? {} + } + +} + +export default AsyncContext diff --git a/logging/src/BeaconReporter.ts b/logging/src/BeaconReporter.ts new file mode 100644 index 00000000..63b1fda5 --- /dev/null +++ b/logging/src/BeaconReporter.ts @@ -0,0 +1,77 @@ +import type { LoggerLevel } from './Level' +import LogReporter from './LogReporter' +import BufferedReporter, { type LogEntry } from './BufferedReporter' + +export interface BeaconReporterOptions { + url: string + maxSize?: number + flushInterval?: number + captureErrors?: boolean + errorScope?: string +} + +class BeaconReporter extends LogReporter { + + private buffer: BufferedReporter + private errorScope: string + + constructor(options: BeaconReporterOptions) { + super() + if (typeof navigator === 'undefined' || typeof (navigator as any).sendBeacon !== 'function') { + throw new Error('BeaconReporter requires navigator.sendBeacon (browser only)') + } + const { + url, + maxSize = 50, + flushInterval = 0, + captureErrors = false, + errorScope = 'window', + } = options + this.errorScope = errorScope + this.buffer = new BufferedReporter(null, { + maxSize, + flushInterval, + onFlush: (entries: LogEntry[]) => { + try { + navigator.sendBeacon(url, JSON.stringify(entries)) + } catch { + // never propagate + } + }, + }) + if (captureErrors) { + this.installErrorHandlers() + } + } + + private installErrorHandlers(): void { + const self = this + if (typeof window === 'undefined') return + const prevOnError = typeof window.onerror === 'function' ? window.onerror : null + window.onerror = function (message, source, lineno, colno, error) { + const msg = error ?? (typeof message === 'string' ? message : String(message)) + self.log('error', self.errorScope, Date.now(), {}, [msg, { source, lineno, colno }]) + if (prevOnError) return (prevOnError as any)(message, source, lineno, colno, error) + return false + } + window.addEventListener('unhandledrejection', (evt: PromiseRejectionEvent) => { + const reason = evt.reason instanceof Error ? evt.reason : { reason: evt.reason } + self.log('error', self.errorScope, Date.now(), {}, ['Unhandled promise rejection', reason]) + }) + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + this.buffer.log(level, scope, time, fields, messages) + } + + flush(): void { + this.buffer.flush() + } + + close(): void { + this.buffer.close() + } + +} + +export default BeaconReporter diff --git a/logging/src/BufferedReporter.ts b/logging/src/BufferedReporter.ts index 1553c9e2..5914b7ba 100644 --- a/logging/src/BufferedReporter.ts +++ b/logging/src/BufferedReporter.ts @@ -4,7 +4,8 @@ import LogReporter from './LogReporter' export interface LogEntry { level: LoggerLevel scope: string - time: string + time: number + fields: Record messages: any[] } @@ -35,16 +36,23 @@ class BufferedReporter extends LogReporter { this.maxSize = options.maxSize ?? DEFAULT_MAX_SIZE this.flushInterval = options.flushInterval ?? DEFAULT_FLUSH_INTERVAL this.onFlushFn = options.onFlush ?? null + if (typeof process !== 'undefined' && typeof process.once === 'function') { + process.once('beforeExit', () => this.flush()) + } + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('pagehide', () => this.flush(), { once: true }) + } } - log(level: LoggerLevel, scope: string, time: string, messages: any[]): void { - this.buffer.push({ level, scope, time, messages }) + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + this.buffer.push({ level, scope, time, fields, messages }) if (this.buffer.length >= this.maxSize) { this.flush() return } if (this.timer === null && this.flushInterval > 0) { this.timer = setTimeout(() => this.flush(), this.flushInterval) + if (typeof (this.timer as any)?.unref === 'function') (this.timer as any).unref() } } @@ -59,12 +67,19 @@ class BufferedReporter extends LogReporter { const entries = this.buffer this.buffer = [] if (this.onFlushFn !== null) { - this.onFlushFn(entries) - return + try { + this.onFlushFn(entries) + } catch (err) { + console.error('[logging] BufferedReporter onFlush threw:', err) + } } if (this.inner !== null) { for (const entry of entries) { - this.inner.log(entry.level, entry.scope, entry.time, entry.messages) + try { + this.inner.log(entry.level, entry.scope, entry.time, entry.fields, entry.messages) + } catch (err) { + console.error('[logging] BufferedReporter inner reporter threw:', err) + } } } } diff --git a/logging/src/ConsoleLogReporter.ts b/logging/src/ConsoleLogReporter.ts index ef0a4450..7270d0f9 100644 --- a/logging/src/ConsoleLogReporter.ts +++ b/logging/src/ConsoleLogReporter.ts @@ -1,18 +1,156 @@ import { LoggerLevel } from './Level' import LogReporter from './LogReporter' +import { type LogFormatter } from './Formatter' + +export interface ConsoleLogReporterOptions { + color?: boolean + timestamp?: boolean + prefix?: string | ((level: LoggerLevel, scope: string, time: number) => string) + objects?: 'compact' | 'pretty' + formatter?: LogFormatter +} + +const ANSI_RESET = '\x1b[0m' + +const ANSI_COLORS: Record = { + error: '\x1b[31m', + warning: '\x1b[33m', + info: '\x1b[36m', + debug: '\x1b[90m', + verbose: '\x1b[90m', +} + +const BROWSER_STYLES: Record = { + error: 'color:#e74c3c;font-weight:bold', + warning: 'color:#e67e22;font-weight:bold', + info: 'color:#2980b9', + debug: 'color:#7f8c8d', + verbose: 'color:#95a5a6', +} + +function isNode(): boolean { + return typeof process !== 'undefined' + && typeof process.versions !== 'undefined' + && typeof process.versions.node !== 'undefined' +} + +function resolveColor(requested: boolean | undefined, node: boolean): boolean { + if (requested === false) return false + if (requested === true) return true + if (node) { + if (typeof process !== 'undefined' && process.env != null && 'NO_COLOR' in process.env) return false + return typeof process !== 'undefined' && process.stdout?.isTTY === true + } + return true +} + +function serializeMessage(x: any): any { + if (x === null || typeof x !== 'object') return x + if (x instanceof Error) { + return x.stack ?? `${x.name}: ${x.message}` + } + try { + return JSON.stringify(x, null, 2) + } catch { + return String(x) + } +} class ConsoleLogReporter extends LogReporter { - log(level: LoggerLevel, scope: string, time: string, messages: any[]): void { - if (level === 'error') { - console.error(`${level.toUpperCase()} [${time}] | ${scope}:`, ...messages) - } else if (level === 'warning') { - console.warn(`${level.toUpperCase()} [${time}] | ${scope}:`, ...messages) + private readonly useColor: boolean + private readonly node: boolean + private readonly showTimestamp: boolean + private readonly prefixOption: ConsoleLogReporterOptions['prefix'] + private readonly objectMode: 'compact' | 'pretty' + private readonly formatterOption?: LogFormatter + + constructor(options: ConsoleLogReporterOptions = {}) { + super() + this.node = isNode() + this.useColor = resolveColor(options.color, this.node) + this.showTimestamp = options.timestamp !== false + this.prefixOption = options.prefix + this.objectMode = options.objects ?? 'compact' + this.formatterOption = options.formatter + } + + private buildPrefix(level: LoggerLevel, scope: string, time: number): string { + if (typeof this.prefixOption === 'function') { + return this.prefixOption(level, scope, time) + } + if (typeof this.prefixOption === 'string') { + return this.prefixOption + } + return this.showTimestamp + ? `${level.toUpperCase()} [${new Date(time).toISOString()}] | ${scope}:` + : `${level.toUpperCase()} | ${scope}:` + } + + private prepareMessages(messages: any[]): any[] { + if (this.objectMode === 'compact') return messages + return messages.map(serializeMessage) + } + + private writeLine(level: LoggerLevel, line: string): void { + if (this.useColor && this.node) { + const ansi = ANSI_COLORS[level] ?? '' + const colored = `${ansi}${line}${ANSI_RESET}` + if (level === 'error') console.error(colored) + else if (level === 'warning') console.warn(colored) + else console.log(colored) + } else if (this.useColor) { + const style = BROWSER_STYLES[level] ?? '' + if (level === 'error') console.error(`%c${line}`, style) + else if (level === 'warning') console.warn(`%c${line}`, style) + else console.log(`%c${line}`, style) } else { - console.log(`${level.toUpperCase()} [${time}] | ${scope}:`, ...messages) + if (level === 'error') console.error(line) + else if (level === 'warning') console.warn(line) + else console.log(line) } } - + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + if (this.formatterOption) { + const line = this.formatterOption(level, scope, time, fields, messages) + this.writeLine(level, line) + return + } + + const prefix = this.buildPrefix(level, scope, time) + const msgs = this.prepareMessages(messages) + + if (this.useColor && this.node) { + const ansi = ANSI_COLORS[level] ?? '' + const colored = `${ansi}${prefix}${ANSI_RESET}` + if (level === 'error') { + console.error(colored, ...msgs) + } else if (level === 'warning') { + console.warn(colored, ...msgs) + } else { + console.log(colored, ...msgs) + } + } else if (this.useColor) { + const style = BROWSER_STYLES[level] ?? '' + if (level === 'error') { + console.error(`%c${prefix}`, style, ...msgs) + } else if (level === 'warning') { + console.warn(`%c${prefix}`, style, ...msgs) + } else { + console.log(`%c${prefix}`, style, ...msgs) + } + } else { + if (level === 'error') { + console.error(prefix, ...msgs) + } else if (level === 'warning') { + console.warn(prefix, ...msgs) + } else { + console.log(prefix, ...msgs) + } + } + } + } export default ConsoleLogReporter diff --git a/logging/src/ContextualReporter.ts b/logging/src/ContextualReporter.ts new file mode 100644 index 00000000..270378ae --- /dev/null +++ b/logging/src/ContextualReporter.ts @@ -0,0 +1,31 @@ +import type { LoggerLevel } from './Level' +import LogReporter from './LogReporter' +import AsyncContext from './AsyncContext' + +class ContextualReporter extends LogReporter { + + private inner: LogReporter + private context: AsyncContext + + constructor(inner: LogReporter, context: AsyncContext) { + super() + this.inner = inner + this.context = context + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + const merged = { ...this.context.getFields(), ...fields } + this.inner.log(level, scope, time, merged, messages) + } + + flush(): void { + this.inner.flush() + } + + close(): void | Promise { + return this.inner.close() + } + +} + +export default ContextualReporter diff --git a/logging/src/FanoutReporter.ts b/logging/src/FanoutReporter.ts new file mode 100644 index 00000000..3fd8c339 --- /dev/null +++ b/logging/src/FanoutReporter.ts @@ -0,0 +1,48 @@ +import type { LoggerLevel } from './Level' +import LogReporter from './LogReporter' + +class FanoutReporter extends LogReporter { + + private reporters: LogReporter[] + + constructor(reporters: LogReporter[]) { + super() + this.reporters = [...reporters] + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + for (const reporter of this.reporters) { + try { + reporter.log(level, scope, time, fields, messages) + } catch (err) { + console.error('[logging] FanoutReporter: reporter threw; isolating:', err) + } + } + } + + flush(): void { + for (const reporter of this.reporters) { + try { + reporter.flush() + } catch (err) { + console.error('[logging] FanoutReporter: reporter threw during flush; isolating:', err) + } + } + } + + async close(): Promise { + for (const reporter of this.reporters) { + try { + await reporter.close() + } catch (err) { + console.error('[logging] FanoutReporter: reporter threw during close; isolating:', err) + } + } + } + +} + +const MultiReporter = FanoutReporter + +export { MultiReporter } +export default FanoutReporter diff --git a/logging/src/FileLogReporter.ts b/logging/src/FileLogReporter.ts index 299c739f..f82d14f6 100644 --- a/logging/src/FileLogReporter.ts +++ b/logging/src/FileLogReporter.ts @@ -1,55 +1,43 @@ -import { LoggerLevel } from './Level' -import LogReporter from './LogReporter' +import { createWriteStream, unlinkSync, renameSync } from 'node:fs' +import StreamReporter, { type StreamLogFormatter, type StreamReporterOptions } from './StreamReporter' -export type FileLogFormatter = (level: LoggerLevel, scope: string, time: string, messages: any[]) => string +export type FileLogFormatter = StreamLogFormatter -export interface FileLogReporterOptions { +export interface FileLogReporterOptions extends StreamReporterOptions { append?: boolean - formatter?: FileLogFormatter + maxFiles?: number } -interface NodeWriteStream { - write(chunk: string): boolean - end(callback?: () => void): void -} - -declare const require: ((id: string) => any) | undefined - -const defaultFormatter: FileLogFormatter = (level, scope, time, messages) => { - const body = messages.map(m => { - if (m instanceof Error) return m.stack || m.message - if (typeof m === 'object' && m !== null) { - try { return JSON.stringify(m) } catch { return String(m) } - } - return String(m) - }).join(' ') - return `${level.toUpperCase()} [${time}] | ${scope}: ${body}` -} +class FileLogReporter extends StreamReporter { -class FileLogReporter extends LogReporter { - - private stream: NodeWriteStream - private formatter: FileLogFormatter + private readonly filePath: string + private readonly maxFiles: number constructor(filePath: string, options: FileLogReporterOptions = {}) { - super() - if (typeof require !== 'function') { - throw new Error('FileLogReporter is only available in Node.js') - } - const fs = require('node:fs') const flags = options.append === false ? 'w' : 'a' - this.stream = fs.createWriteStream(filePath, { flags }) - this.formatter = options.formatter ?? defaultFormatter + super(createWriteStream(filePath, { flags }), options) + this.filePath = filePath + this.maxFiles = options.maxFiles ?? 5 } - log(level: LoggerLevel, scope: string, time: string, messages: any[]): void { - this.stream.write(this.formatter(level, scope, time, messages) + '\n') + protected openRotatedStream(): Promise { + return new Promise(resolve => { + this.stream.end(() => { + this.shiftFiles() + this.bytesWritten = 0 + this.stream = createWriteStream(this.filePath, { flags: 'a' }) + this.stream.on('error', err => this.onError?.(err)) + resolve() + }) + }) } - close(): Promise { - return new Promise(resolve => { - this.stream.end(() => resolve()) - }) + private shiftFiles(): void { + try { unlinkSync(`${this.filePath}.${this.maxFiles}`) } catch {} + for (let i = this.maxFiles - 1; i >= 1; i--) { + try { renameSync(`${this.filePath}.${i}`, `${this.filePath}.${i + 1}`) } catch {} + } + try { renameSync(this.filePath, `${this.filePath}.1`) } catch {} } } diff --git a/logging/src/Formatter.ts b/logging/src/Formatter.ts new file mode 100644 index 00000000..537e23f4 --- /dev/null +++ b/logging/src/Formatter.ts @@ -0,0 +1,70 @@ +import { LoggerLevel } from './Level' + +export type LogFormatter = (level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]) => string + +function messageToString(m: any): string { + if (m instanceof Error) return m.stack || m.message + if (typeof m === 'object' && m !== null) { + try { return JSON.stringify(m) } catch { return String(m) } + } + return String(m) +} + +export const textFormatter: LogFormatter = (level, scope, time, _fields, messages) => { + const body = messages.map(messageToString).join(' ') + return `${level.toUpperCase()} [${new Date(time).toISOString()}] | ${scope}: ${body}` +} + +function safeWalk(value: any): any { + const seen = new WeakSet() + const walk = (v: any): any => { + if (v instanceof Error) return { name: v.name, message: v.message, stack: v.stack } + if (typeof v === 'bigint') return v.toString() + if (v && typeof v === 'object') { + if (seen.has(v)) return '[Circular]' + seen.add(v) + if (Array.isArray(v)) return v.map(walk) + const out: Record = {} + for (const k of Object.keys(v)) out[k] = walk(v[k]) + return out + } + return v + } + try { return walk(value) } catch { return '' } +} + +export const jsonFormatter: LogFormatter = (level, scope, time, fields, messages) => { + const record = { ...fields, level, scope, time, messages: messages.map(safeWalk) } + try { + return JSON.stringify(record) + } catch { + const fallback = { ...fields, level, scope, time, messages: [''] } + return JSON.stringify(fallback) + } +} + +function logfmtEscape(v: string): string { + if (v === '') return '""' + if (/[\s"=]/.test(v)) return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + return v +} + +function messageToLogfmt(m: any): string { + if (typeof m === 'string') return m + if (m instanceof Error) return m.message + try { return JSON.stringify(m) } catch { return String(m) } +} + +export const logfmtFormatter: LogFormatter = (level, scope, time, fields, messages) => { + const pairs: string[] = [ + `level=${level}`, + `scope=${logfmtEscape(scope)}`, + `ts=${new Date(time).toISOString()}`, + ] + for (const [k, v] of Object.entries(fields)) { + pairs.push(`${k}=${logfmtEscape(String(v))}`) + } + const msg = messages.map(messageToLogfmt).join(' ') + pairs.push(`msg=${logfmtEscape(msg)}`) + return pairs.join(' ') +} diff --git a/logging/src/HTTPReporter.ts b/logging/src/HTTPReporter.ts new file mode 100644 index 00000000..bf34f06e --- /dev/null +++ b/logging/src/HTTPReporter.ts @@ -0,0 +1,93 @@ +import { LoggerLevel } from './Level' +import LogReporter from './LogReporter' +import BufferedReporter, { type LogEntry } from './BufferedReporter' + +export type HTTPTransport = (url: string, body: string, headers: Record) => Promise + +export interface HTTPReporterOptions { + url: string + headers?: Record + maxSize?: number + flushInterval?: number + retries?: number + retryMinTimeout?: number + transport?: HTTPTransport +} + +const defaultTransport: HTTPTransport = async (url, body, headers) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body, + }) + return res.status +} + +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) + +const postWithRetry = async ( + url: string, + body: string, + headers: Record, + retries: number, + minTimeout: number, + transport: HTTPTransport +): Promise => { + let attempt = 0 + while (true) { + if (attempt > 0) { + await sleep(minTimeout * Math.pow(2, attempt - 1)) + } + try { + const status = await transport(url, body, headers) + if (status < 200 || status >= 300) { + throw new Error(`HTTP ${status}`) + } + return + } catch { + if (attempt >= retries) return + attempt++ + } + } +} + +class HTTPReporter extends LogReporter { + + private buffer: BufferedReporter + + constructor(options: HTTPReporterOptions) { + super() + const { + url, + headers = {}, + maxSize = 50, + flushInterval = 1000, + retries = 3, + retryMinTimeout = 500, + transport = defaultTransport, + } = options + this.buffer = new BufferedReporter(null, { + maxSize, + flushInterval, + onFlush: (entries: LogEntry[]) => { + const body = JSON.stringify({ entries }) + postWithRetry(url, body, headers, retries, retryMinTimeout, transport).catch(() => {}) + }, + }) + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + this.buffer.log(level, scope, time, fields, messages) + } + + flush(): void { + this.buffer.flush() + } + + close(): void { + this.buffer.close() + } + +} + +export default HTTPReporter diff --git a/logging/src/IndexedDBReporter.ts b/logging/src/IndexedDBReporter.ts new file mode 100644 index 00000000..21616cb1 --- /dev/null +++ b/logging/src/IndexedDBReporter.ts @@ -0,0 +1,105 @@ +import type { LoggerLevel } from './Level' +import LogReporter from './LogReporter' +import type { LogEntry } from './BufferedReporter' + +export interface IndexedDBReporterOptions { + dbName?: string + storeName?: string + maxEntries?: number +} + +class IndexedDBReporter extends LogReporter { + + private readonly dbName: string + private readonly storeName: string + private readonly maxEntries: number + private readonly dbPromise: Promise + private writeChain: Promise = Promise.resolve() + private closed = false + + constructor(options: IndexedDBReporterOptions = {}) { + super() + if (typeof indexedDB === 'undefined') { + throw new Error('IndexedDBReporter requires IndexedDB (browser only)') + } + this.dbName = options.dbName ?? '@toolcase/logging' + this.storeName = options.storeName ?? 'log-entries' + this.maxEntries = options.maxEntries ?? 1000 + this.dbPromise = this.openDB() + } + + private openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(this.dbName, 1) + req.onupgradeneeded = () => { + const db = req.result + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName, { autoIncrement: true }) + } + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + if (this.closed) return + const entry: LogEntry = { level, scope, time, fields, messages } + this.writeChain = this.writeChain.then(() => this.write(entry)).catch(() => {}) + } + + private write(entry: LogEntry): Promise { + return this.dbPromise.then(db => new Promise((resolve) => { + const tx = db.transaction(this.storeName, 'readwrite') + const store = tx.objectStore(this.storeName) + store.add(entry) + const countReq = store.count() + countReq.onsuccess = () => { + const excess = (countReq.result as number) - this.maxEntries + if (excess > 0) { + let deleted = 0 + const cursorReq = store.openCursor() + cursorReq.onsuccess = () => { + const cursor = (cursorReq as IDBRequest).result + if (cursor && deleted < excess) { + cursor.delete() + deleted++ + cursor.continue() + } + } + } + } + tx.oncomplete = () => resolve() + tx.onerror = () => resolve() + })).catch(() => {}) + } + + flush(): Promise { + return this.writeChain + } + + drain(): Promise { + return this.writeChain.then(() => this.dbPromise.then(db => new Promise((resolve) => { + const tx = db.transaction(this.storeName, 'readwrite') + const store = tx.objectStore(this.storeName) + const getAllReq = store.getAll() + getAllReq.onsuccess = () => { + const entries = getAllReq.result as LogEntry[] + store.clear() + tx.oncomplete = () => resolve(entries) + } + getAllReq.onerror = () => resolve([]) + tx.onerror = () => resolve([]) + }))).catch(() => []) + } + + close(): Promise { + this.closed = true + return this.flush().then(() => { + this.dbPromise.then(db => db.close()).catch(() => {}) + }) + } + +} + +export default IndexedDBReporter diff --git a/logging/src/JSONLineReporter.ts b/logging/src/JSONLineReporter.ts index 9b23da69..8645a718 100644 --- a/logging/src/JSONLineReporter.ts +++ b/logging/src/JSONLineReporter.ts @@ -1,11 +1,13 @@ import { LoggerLevel } from './Level' import LogReporter from './LogReporter' +import { type LogFormatter, jsonFormatter } from './Formatter' export type JSONLineWriter = (line: string) => void export interface JSONLineReporterOptions { write?: JSONLineWriter extra?: Record + formatter?: LogFormatter } const defaultWrite: JSONLineWriter = line => console.log(line) @@ -14,35 +16,21 @@ class JSONLineReporter extends LogReporter { private writeFn: JSONLineWriter private extra: Record + private formatter: LogFormatter constructor(options: JSONLineReporterOptions = {}) { super() this.writeFn = options.write ?? defaultWrite this.extra = options.extra ?? {} + this.formatter = options.formatter ?? jsonFormatter } - log(level: LoggerLevel, scope: string, time: string, messages: any[]): void { - const record = { - ...this.extra, - level, - scope, - time, - messages: messages.map(serialize) - } - try { - this.writeFn(JSON.stringify(record)) - } catch { - this.writeFn(JSON.stringify({ ...this.extra, level, scope, time, messages: [''] })) - } + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + const mergedFields = { ...this.extra, ...fields } + const line = this.formatter(level, scope, time, mergedFields, messages) + this.writeFn(line) } } -const serialize = (value: any): any => { - if (value instanceof Error) { - return { name: value.name, message: value.message, stack: value.stack } - } - return value -} - export default JSONLineReporter diff --git a/logging/src/Level.ts b/logging/src/Level.ts index 20f9d27b..bdb90913 100644 --- a/logging/src/Level.ts +++ b/logging/src/Level.ts @@ -18,21 +18,19 @@ const Level: Record = { VERBOSE: 'verbose' } -const getLevelOrder = (level: LoggerLevel): number => { - const order = LevelOrder[level] ?? null - if (typeof order !== 'number') { - return -1 - } - return order -} +const OrderToLevel = Object.fromEntries( + Object.entries(LevelOrder).map(([k, v]) => [v, k]) +) as Record -const getLevel = (levelOrder: number): LoggerLevel => { - const level = Object.keys(LevelOrder).find(key => LevelOrder[key as LoggerLevel] === levelOrder) || null - if (level === null) { - return 'silent' - } - return level as LoggerLevel -} +const getLevelOrder = (level: LoggerLevel): number => + LevelOrder[level] ?? Number.POSITIVE_INFINITY + +const getLevel = (order: number): LoggerLevel => OrderToLevel[order] ?? 'silent' + +const KNOWN_LEVELS = Object.keys(LevelOrder) as LoggerLevel[] + +const isKnownLevel = (level: string): level is LoggerLevel => + Object.prototype.hasOwnProperty.call(LevelOrder, level) export default Level -export { getLevelOrder, getLevel } +export { getLevelOrder, getLevel, isKnownLevel, KNOWN_LEVELS } diff --git a/logging/src/LevelFilterReporter.ts b/logging/src/LevelFilterReporter.ts new file mode 100644 index 00000000..22537242 --- /dev/null +++ b/logging/src/LevelFilterReporter.ts @@ -0,0 +1,32 @@ +import type { LoggerLevel } from './Level' +import { getLevelOrder } from './Level' +import LogReporter from './LogReporter' + +class LevelFilterReporter extends LogReporter { + + private inner: LogReporter + private minOrder: number + + constructor(inner: LogReporter, minLevel: LoggerLevel) { + super() + this.inner = inner + this.minOrder = getLevelOrder(minLevel) + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + if (getLevelOrder(level) <= this.minOrder) { + this.inner.log(level, scope, time, fields, messages) + } + } + + flush(): void { + this.inner.flush() + } + + close(): void | Promise { + return this.inner.close() + } + +} + +export default LevelFilterReporter diff --git a/logging/src/LogReporter.ts b/logging/src/LogReporter.ts index e5742d75..677a7976 100644 --- a/logging/src/LogReporter.ts +++ b/logging/src/LogReporter.ts @@ -2,7 +2,11 @@ import { LoggerLevel } from './Level' class LogReporter { - log(_level: LoggerLevel, _scope: string, _time: string, _messages: any[]): void {} + log(_level: LoggerLevel, _scope: string, _time: number, _fields: Record, _messages: any[]): void {} + + flush(): void {} + + close(): void | Promise {} } diff --git a/logging/src/Logger.ts b/logging/src/Logger.ts index f900afa6..20b2b391 100644 --- a/logging/src/Logger.ts +++ b/logging/src/Logger.ts @@ -1,6 +1,8 @@ -import { LoggerLevel, getLevel, getLevelOrder } from './Level' +import { LoggerLevel, getLevel, getLevelOrder, isKnownLevel } from './Level' -export type LogMessageFn = (level: LoggerLevel, scope: string, time: string, messages: any[], overrideOrder?: number | null) => void +export type ClockFn = () => number +export type LogMessageFn = (level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[], overrideOrder?: number | null) => void +export type IsEnabledFn = (order: number, overrideOrder: number | null, scope: string) => boolean class Logger { @@ -8,11 +10,15 @@ class Logger { private logMessageFn: LogMessageFn private levelOverride: number | null = null private context: Record | null + private isEnabledFn: IsEnabledFn | null + private clock: ClockFn - constructor(scope: string, logMessage: LogMessageFn, context: Record | null = null) { + constructor(scope: string, logMessage: LogMessageFn, context: Record | null = null, isEnabled: IsEnabledFn | null = null, clock: ClockFn = Date.now) { this.scope = scope this.logMessageFn = logMessage this.context = context + this.isEnabledFn = isEnabled + this.clock = clock } error(...args: any[]): void { @@ -35,10 +41,23 @@ class Logger { this.log('verbose', ...args) } + isEnabled(level: LoggerLevel): boolean { + if (!this.isEnabledFn) { + return true + } + return this.isEnabledFn(getLevelOrder(level), this.levelOverride, this.scope) + } + log(level: LoggerLevel, ...args: any[]): void { - const time = new Date().toISOString() - const messages = this.context === null ? args : [this.context, ...args] - this.logMessageFn(level, this.scope, time, messages, this.levelOverride) + if (!isKnownLevel(level)) return + const order = getLevelOrder(level) + if (this.isEnabledFn && !this.isEnabledFn(order, this.levelOverride, this.scope)) { + return + } + const time = this.clock() + const evaluated = args.map(a => typeof a === 'function' ? a() : a) + const fields = this.context ?? {} + this.logMessageFn(level, this.scope, time, fields, evaluated, this.levelOverride) } setLevel(level: LoggerLevel | null): void { @@ -51,11 +70,18 @@ class Logger { withContext(context: Record): Logger { const merged = { ...(this.context ?? {}), ...context } - const child = new Logger(this.scope, this.logMessageFn, merged) + const child = new Logger(this.scope, this.logMessageFn, merged, this.isEnabledFn, this.clock) child.levelOverride = this.levelOverride return child } + child(childScope: string): Logger { + const scope = `${this.scope}:${childScope}` + const logger = new Logger(scope, this.logMessageFn, this.context, this.isEnabledFn, this.clock) + logger.levelOverride = this.levelOverride + return logger + } + } export default Logger diff --git a/logging/src/LoggerFactory.ts b/logging/src/LoggerFactory.ts index ee1d0d98..515214c7 100644 --- a/logging/src/LoggerFactory.ts +++ b/logging/src/LoggerFactory.ts @@ -1,19 +1,41 @@ -import { LoggerLevel, getLevel, getLevelOrder } from './Level' +import { LoggerLevel, getLevel, getLevelOrder, isKnownLevel, KNOWN_LEVELS } from './Level' import Logger from './Logger' import LogReporter from './LogReporter' +function scopePatternToRegex(pattern: string): RegExp { + let re = '^' + for (const c of pattern) { + if (c === '*') re += '.*' + else if (/[.+?^${}()|[\]\\]/.test(c)) re += `\\${c}` + else re += c + } + return new RegExp(re + '$') +} + +function patternSpecificity(pattern: string): number { + return pattern.split('*').join('').length +} + +type PatternEntry = { pattern: string, re: RegExp, order: number, specificity: number } + class LoggerFactory { private loggers: Map = new Map() private reporters: LogReporter[] private levelOrder!: number + private clockFn: () => number + private patternLevels: PatternEntry[] = [] - constructor(reporters: LogReporter[] = []) { + constructor(reporters: LogReporter[] = [], clock: () => number = Date.now) { this.reporters = reporters + this.clockFn = clock this.level = 'info' } set level(level: LoggerLevel) { + if (!isKnownLevel(level)) { + throw new RangeError(`Unknown log level: "${level}". Valid levels: ${KNOWN_LEVELS.join(', ')}`) + } this.levelOrder = getLevelOrder(level) } @@ -21,22 +43,108 @@ class LoggerFactory { return getLevel(this.levelOrder) } + setLevel(pattern: string, level: LoggerLevel): void { + if (!isKnownLevel(level)) { + throw new RangeError(`Unknown log level: "${level}". Valid levels: ${KNOWN_LEVELS.join(', ')}`) + } + const re = scopePatternToRegex(pattern) + const order = getLevelOrder(level) + const specificity = patternSpecificity(pattern) + const existing = this.patternLevels.findIndex(p => p.pattern === pattern) + if (existing !== -1) { + this.patternLevels[existing] = { pattern, re, order, specificity } + } else { + this.patternLevels.push({ pattern, re, order, specificity }) + } + } + + parseEnv(env: Record): void { + const logLevel = env['LOG_LEVEL'] + if (logLevel && isKnownLevel(logLevel)) { + this.level = logLevel + } + const debug = env['DEBUG'] + if (debug) { + const patterns = debug.split(/[\s,]+/).filter(Boolean) + for (const pattern of patterns) { + this.setLevel(pattern, 'debug') + } + } + } + getLogger(scope: string = 'default'): Logger { if (!this.loggers.has(scope)) { - const logger = new Logger(scope, this.onLog) + const logger = new Logger(scope, this.onLog, null, this.isEnabled, this.clockFn) this.loggers.set(scope, logger) } return this.loggers.get(scope)! } - private onLog = (level: LoggerLevel, scope: string, time: string, messages: any[], overrideOrder?: number | null): void => { + addReporter(reporter: LogReporter): void { + this.reporters.push(reporter) + } + + removeReporter(reporter: LogReporter): void { + const i = this.reporters.indexOf(reporter) + if (i !== -1) this.reporters.splice(i, 1) + } + + flush(): void { + for (const reporter of this.reporters) { + try { + reporter.flush() + } catch (err) { + console.error('[logging] reporter flush threw:', err) + } + } + } + + async close(): Promise { + for (const reporter of this.reporters) { + try { + await reporter.close() + } catch (err) { + console.error('[logging] reporter close threw:', err) + } + } + } + + private resolvePatternLevel(scope: string): number | null { + let best: { specificity: number, order: number, index: number } | null = null + for (let i = 0; i < this.patternLevels.length; i++) { + const p = this.patternLevels[i] + if (p.re.test(scope)) { + if ( + best === null || + p.specificity > best.specificity || + (p.specificity === best.specificity && i > best.index) + ) { + best = { specificity: p.specificity, order: p.order, index: i } + } + } + } + return best !== null ? best.order : null + } + + private isEnabled = (order: number, overrideOrder: number | null, scope: string): boolean => { + const patternOrder = this.patternLevels.length ? this.resolvePatternLevel(scope) : null + const threshold = overrideOrder ?? patternOrder ?? this.levelOrder + return threshold >= order + } + + private onLog = (level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[], overrideOrder?: number | null): void => { const order = getLevelOrder(level) - const threshold = (overrideOrder === null || overrideOrder === undefined) ? this.levelOrder : overrideOrder + const patternOrder = this.patternLevels.length ? this.resolvePatternLevel(scope) : null + const threshold = (overrideOrder == null) ? (patternOrder ?? this.levelOrder) : overrideOrder if (threshold < order) { return } for (const reporter of this.reporters) { - reporter.log(level, scope, time, messages) + try { + reporter.log(level, scope, time, fields, messages) + } catch (err) { + console.error('[logging] reporter threw; isolating:', err) + } } } diff --git a/logging/src/MemoryReporter.ts b/logging/src/MemoryReporter.ts new file mode 100644 index 00000000..f867a9fd --- /dev/null +++ b/logging/src/MemoryReporter.ts @@ -0,0 +1,27 @@ +import { LoggerLevel } from './Level' +import LogReporter from './LogReporter' +import { type LogEntry } from './BufferedReporter' + +class MemoryReporter extends LogReporter { + + private _entries: LogEntry[] = [] + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + this._entries.push({ level, scope, time, fields, messages }) + } + + entries(): LogEntry[] { + return this._entries.slice() + } + + find(level: LoggerLevel): LogEntry[] { + return this._entries.filter(e => e.level === level) + } + + clear(): void { + this._entries = [] + } + +} + +export default MemoryReporter diff --git a/logging/src/OTLPReporter.ts b/logging/src/OTLPReporter.ts new file mode 100644 index 00000000..be0cdc08 --- /dev/null +++ b/logging/src/OTLPReporter.ts @@ -0,0 +1,178 @@ +import { LoggerLevel } from './Level' +import LogReporter from './LogReporter' +import BufferedReporter, { type LogEntry } from './BufferedReporter' + +export type OTLPTransport = (url: string, body: string, headers: Record) => Promise + +export interface OTLPReporterOptions { + url: string + headers?: Record + resource?: Record + maxSize?: number + flushInterval?: number + retries?: number + retryMinTimeout?: number + transport?: OTLPTransport +} + +type AnyValue = + | { stringValue: string } + | { boolValue: boolean } + | { intValue: string } + | { doubleValue: number } + | { arrayValue: { values: AnyValue[] } } + | { kvlistValue: { values: KeyValue[] } } + +type KeyValue = { key: string; value: AnyValue } + +const SEVERITY: Record = { + verbose: [1, 'TRACE'], + debug: [5, 'DEBUG'], + info: [9, 'INFO'], + warning: [13, 'WARN'], + error: [17, 'ERROR'], + silent: [0, 'UNSPECIFIED'], +} + +const defaultTransport: OTLPTransport = async (url, body, headers) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body, + }) + return res.status +} + +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) + +const postWithRetry = async ( + url: string, + body: string, + headers: Record, + retries: number, + minTimeout: number, + transport: OTLPTransport +): Promise => { + let attempt = 0 + while (true) { + if (attempt > 0) { + await sleep(minTimeout * Math.pow(2, attempt - 1)) + } + try { + const status = await transport(url, body, headers) + if (status < 200 || status >= 300) { + throw new Error(`HTTP ${status}`) + } + return + } catch { + if (attempt >= retries) return + attempt++ + } + } +} + +function toAnyValue(v: any): AnyValue { + if (typeof v === 'string') return { stringValue: v } + if (typeof v === 'boolean') return { boolValue: v } + if (typeof v === 'number') { + return Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v } + } + if (typeof v === 'bigint') return { stringValue: String(v) } + if (Array.isArray(v)) return { arrayValue: { values: v.map(toAnyValue) } } + if (v !== null && typeof v === 'object') { + return { + kvlistValue: { + values: Object.entries(v).map(([k, val]) => ({ key: k, value: toAnyValue(val) })) + } + } + } + return { stringValue: String(v) } +} + +function toKeyValues(obj: Record): KeyValue[] { + return Object.entries(obj).map(([k, v]) => ({ key: k, value: toAnyValue(v) })) +} + +function serializeMessage(m: any): string { + if (typeof m === 'string') return m + try { return JSON.stringify(m) } catch { return String(m) } +} + +function buildPayload(entries: LogEntry[], resource: Record): string { + const byScope = new Map() + for (const entry of entries) { + const group = byScope.get(entry.scope) + if (group) { + group.push(entry) + } else { + byScope.set(entry.scope, [entry]) + } + } + + const scopeLogs = Array.from(byScope.entries()).map(([scope, scopeEntries]) => ({ + scope: { name: scope }, + logRecords: scopeEntries.map(entry => { + const [severityNumber, severityText] = SEVERITY[entry.level] ?? [0, 'UNSPECIFIED'] + const attributes: KeyValue[] = [ + { key: 'log.scope', value: { stringValue: scope } }, + ...toKeyValues(entry.fields), + ] + return { + timeUnixNano: String(entry.time * 1_000_000), + severityNumber, + severityText, + body: { stringValue: entry.messages.map(serializeMessage).join(' ') }, + attributes, + } + }), + })) + + return JSON.stringify({ + resourceLogs: [{ + resource: { attributes: toKeyValues(resource) }, + scopeLogs, + }] + }) +} + +class OTLPReporter extends LogReporter { + + private buffer: BufferedReporter + + constructor(options: OTLPReporterOptions) { + super() + const { + url, + headers = {}, + resource = {}, + maxSize = 50, + flushInterval = 1000, + retries = 3, + retryMinTimeout = 500, + transport = defaultTransport, + } = options + this.buffer = new BufferedReporter(null, { + maxSize, + flushInterval, + onFlush: (entries: LogEntry[]) => { + const body = buildPayload(entries, resource) + postWithRetry(url, body, headers, retries, retryMinTimeout, transport).catch(() => {}) + }, + }) + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + this.buffer.log(level, scope, time, fields, messages) + } + + flush(): void { + this.buffer.flush() + } + + close(): void { + this.buffer.close() + } + +} + +export default OTLPReporter diff --git a/logging/src/RedactionReporter.ts b/logging/src/RedactionReporter.ts new file mode 100644 index 00000000..e0574737 --- /dev/null +++ b/logging/src/RedactionReporter.ts @@ -0,0 +1,55 @@ +import type { LoggerLevel } from './Level' +import LogReporter from './LogReporter' + +export type RedactionKeys = string[] | RegExp + +const REDACTED = '[REDACTED]' + +const matchKey = (key: string, keys: RedactionKeys): boolean => + Array.isArray(keys) ? keys.includes(key) : keys.test(key) + +const redact = (value: unknown, keys: RedactionKeys, seen: WeakSet): unknown => { + if (value === null || typeof value !== 'object') return value + if (value instanceof Error) return value + if (seen.has(value as object)) return value + seen.add(value as object) + if (Array.isArray(value)) { + return value.map((item) => redact(item, keys, seen)) + } + const result: Record = {} + for (const key of Object.keys(value as object)) { + result[key] = matchKey(key, keys) + ? REDACTED + : redact((value as Record)[key], keys, seen) + } + return result +} + +class RedactionReporter extends LogReporter { + + private inner: LogReporter + private keys: RedactionKeys + + constructor(inner: LogReporter, keys: RedactionKeys) { + super() + this.inner = inner + this.keys = keys + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + const redactedFields = redact(fields, this.keys, new WeakSet()) as Record + const redactedMessages = messages.map((m) => redact(m, this.keys, new WeakSet())) + this.inner.log(level, scope, time, redactedFields, redactedMessages) + } + + flush(): void { + this.inner.flush() + } + + close(): void | Promise { + return this.inner.close() + } + +} + +export default RedactionReporter diff --git a/logging/src/RingBufferReporter.ts b/logging/src/RingBufferReporter.ts new file mode 100644 index 00000000..5528fda9 --- /dev/null +++ b/logging/src/RingBufferReporter.ts @@ -0,0 +1,55 @@ +import type { LoggerLevel } from './Level' +import LogReporter from './LogReporter' +import type { LogEntry } from './BufferedReporter' + +class RingBufferReporter extends LogReporter { + + private readonly cap: number + private readonly buf: (LogEntry | undefined)[] + private head: number = 0 + private count: number = 0 + + constructor(capacity: number) { + super() + if (!Number.isInteger(capacity) || capacity < 1) { + throw new Error('RingBufferReporter: capacity must be a positive integer') + } + this.cap = capacity + this.buf = new Array(capacity) + } + + get size(): number { + return this.count + } + + get capacity(): number { + return this.cap + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + const entry: LogEntry = { level, scope, time, fields, messages } + if (this.count < this.cap) { + this.buf[(this.head + this.count) % this.cap] = entry + this.count++ + } else { + this.buf[this.head] = entry + this.head = (this.head + 1) % this.cap + } + } + + snapshot(): LogEntry[] { + const result: LogEntry[] = new Array(this.count) + for (let i = 0; i < this.count; i++) { + result[i] = this.buf[(this.head + i) % this.cap] as LogEntry + } + return result + } + + clear(): void { + this.head = 0 + this.count = 0 + } + +} + +export default RingBufferReporter diff --git a/logging/src/SamplingReporter.ts b/logging/src/SamplingReporter.ts new file mode 100644 index 00000000..e5798848 --- /dev/null +++ b/logging/src/SamplingReporter.ts @@ -0,0 +1,34 @@ +import type { LoggerLevel } from './Level' +import LogReporter from './LogReporter' + +class SamplingReporter extends LogReporter { + + private inner: LogReporter + private rate: number + + constructor(inner: LogReporter, rate: number) { + super() + if (rate < 0 || rate > 1) { + throw new RangeError('SamplingReporter: rate must be between 0 and 1 inclusive') + } + this.inner = inner + this.rate = rate + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + if (Math.random() < this.rate) { + this.inner.log(level, scope, time, fields, messages) + } + } + + flush(): void { + this.inner.flush() + } + + close(): void | Promise { + return this.inner.close() + } + +} + +export default SamplingReporter diff --git a/logging/src/ScopeFilterReporter.ts b/logging/src/ScopeFilterReporter.ts new file mode 100644 index 00000000..5d5bb3a9 --- /dev/null +++ b/logging/src/ScopeFilterReporter.ts @@ -0,0 +1,41 @@ +import type { LoggerLevel } from './Level' +import LogReporter from './LogReporter' + +function scopePatternToRegex(pattern: string): RegExp { + let re = '^' + for (const c of pattern) { + if (c === '*') re += '.*' + else if (/[.+?^${}()|[\]\\]/.test(c)) re += `\\${c}` + else re += c + } + return new RegExp(re + '$') +} + +class ScopeFilterReporter extends LogReporter { + + private inner: LogReporter + private regex: RegExp + + constructor(inner: LogReporter, pattern: string) { + super() + this.inner = inner + this.regex = scopePatternToRegex(pattern) + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + if (this.regex.test(scope)) { + this.inner.log(level, scope, time, fields, messages) + } + } + + flush(): void { + this.inner.flush() + } + + close(): void | Promise { + return this.inner.close() + } + +} + +export default ScopeFilterReporter diff --git a/logging/src/StreamReporter.ts b/logging/src/StreamReporter.ts new file mode 100644 index 00000000..f7728965 --- /dev/null +++ b/logging/src/StreamReporter.ts @@ -0,0 +1,80 @@ +import { type Writable } from 'node:stream' +import { type LoggerLevel } from './Level' +import LogReporter from './LogReporter' +import { type LogFormatter, textFormatter } from './Formatter' + +export type StreamLogFormatter = LogFormatter + +export interface StreamReporterOptions { + formatter?: LogFormatter + onError?: (err: Error) => void + maxBytes?: number +} + +export { textFormatter as defaultStreamFormatter } + +class StreamReporter extends LogReporter { + + protected stream: Writable + protected readonly formatter: LogFormatter + protected readonly maxBytes: number + protected readonly onError?: (err: Error) => void + protected bytesWritten = 0 + private rotating = false + private pending: string[] = [] + private rotationDone: Promise | null = null + + constructor(stream: Writable, options: StreamReporterOptions = {}) { + super() + this.stream = stream + this.formatter = options.formatter ?? textFormatter + this.maxBytes = options.maxBytes ?? 0 + this.onError = options.onError + this.stream.on('error', err => this.onError?.(err)) + } + + log(level: LoggerLevel, scope: string, time: number, fields: Record, messages: any[]): void { + const line = this.formatter(level, scope, time, fields, messages) + '\n' + if (this.rotating) { + this.pending.push(line) + return + } + this.writeLine(line) + } + + private writeLine(line: string): void { + const lineBytes = Buffer.byteLength(line) + if (this.maxBytes > 0 && this.bytesWritten + lineBytes > this.maxBytes) { + this.pending.push(line) + this.rotate() + return + } + this.bytesWritten += lineBytes + this.stream.write(line) + } + + private rotate(): void { + this.rotating = true + this.rotationDone = this.openRotatedStream().then(() => { + this.rotating = false + const flushed = this.pending.splice(0) + for (const line of flushed) { + this.bytesWritten += Buffer.byteLength(line) + this.stream.write(line) + } + }) + } + + protected openRotatedStream(): Promise { + this.bytesWritten = 0 + return Promise.resolve() + } + + close(): Promise { + const rotation = this.rotationDone ?? Promise.resolve() + return rotation.then(() => new Promise(resolve => this.stream.end(() => resolve()))) + } + +} + +export default StreamReporter diff --git a/logging/src/browser.ts b/logging/src/browser.ts new file mode 100644 index 00000000..722ea3a3 --- /dev/null +++ b/logging/src/browser.ts @@ -0,0 +1,5 @@ +import BeaconReporter, { type BeaconReporterOptions } from './BeaconReporter' +import IndexedDBReporter, { type IndexedDBReporterOptions } from './IndexedDBReporter' + +export { BeaconReporter, IndexedDBReporter } +export type { BeaconReporterOptions, IndexedDBReporterOptions } diff --git a/logging/src/main.ts b/logging/src/main.ts index 7f12ebab..a9a0280a 100644 --- a/logging/src/main.ts +++ b/logging/src/main.ts @@ -1,10 +1,21 @@ import Logger from './Logger' import Level from './Level' +import { isKnownLevel, KNOWN_LEVELS } from './Level' import LoggerFactory from './LoggerFactory' import LogReporter from './LogReporter' import ConsoleLogReporter from './ConsoleLogReporter' import JSONLineReporter from './JSONLineReporter' import BufferedReporter from './BufferedReporter' +import RingBufferReporter from './RingBufferReporter' +import LevelFilterReporter from './LevelFilterReporter' +import ScopeFilterReporter from './ScopeFilterReporter' +import RedactionReporter from './RedactionReporter' +import SamplingReporter from './SamplingReporter' +import FanoutReporter, { MultiReporter } from './FanoutReporter' +import HTTPReporter from './HTTPReporter' +import OTLPReporter from './OTLPReporter' +import MemoryReporter from './MemoryReporter' +import { textFormatter, jsonFormatter, logfmtFormatter } from './Formatter' const logging = new LoggerFactory([ new ConsoleLogReporter() @@ -12,4 +23,13 @@ const logging = new LoggerFactory([ export default logging -export { logging, Logger, Level, LoggerFactory, LogReporter, ConsoleLogReporter, JSONLineReporter, BufferedReporter } +export { logging, Logger, Level, LoggerFactory, LogReporter, ConsoleLogReporter, JSONLineReporter, BufferedReporter, RingBufferReporter, isKnownLevel, KNOWN_LEVELS, LevelFilterReporter, ScopeFilterReporter, RedactionReporter, SamplingReporter, FanoutReporter, MultiReporter, HTTPReporter, OTLPReporter, MemoryReporter, textFormatter, jsonFormatter, logfmtFormatter } +export type { LoggerLevel } from './Level' +export type { ConsoleLogReporterOptions } from './ConsoleLogReporter' +export type { ClockFn } from './Logger' +export type { LogEntry } from './BufferedReporter' +export type { RedactionKeys } from './RedactionReporter' +export type { HTTPReporterOptions, HTTPTransport } from './HTTPReporter' +export type { OTLPReporterOptions, OTLPTransport } from './OTLPReporter' +export type { LogFormatter } from './Formatter' +export type { JSONLineReporterOptions, JSONLineWriter } from './JSONLineReporter' diff --git a/logging/src/node.ts b/logging/src/node.ts index b0ee7ac2..9c022dac 100644 --- a/logging/src/node.ts +++ b/logging/src/node.ts @@ -1,4 +1,7 @@ +import StreamReporter, { defaultStreamFormatter, type StreamLogFormatter, type StreamReporterOptions } from './StreamReporter' import FileLogReporter, { type FileLogFormatter, type FileLogReporterOptions } from './FileLogReporter' +import AsyncContext from './AsyncContext' +import ContextualReporter from './ContextualReporter' -export { FileLogReporter } -export type { FileLogFormatter, FileLogReporterOptions } +export { StreamReporter, FileLogReporter, defaultStreamFormatter, AsyncContext, ContextualReporter } +export type { StreamLogFormatter, StreamReporterOptions, FileLogFormatter, FileLogReporterOptions } diff --git a/logging/test/logging.test.ts b/logging/test/logging.test.ts index c8960be0..3549899c 100644 --- a/logging/test/logging.test.ts +++ b/logging/test/logging.test.ts @@ -1,12 +1,32 @@ import { describe, it, expect, vi } from 'vitest' -import { default as Level, getLevelOrder, getLevel } from '../src/Level' +import { default as Level, getLevelOrder, getLevel, isKnownLevel, KNOWN_LEVELS } from '../src/Level' import Logger from '../src/Logger' import LoggerFactory from '../src/LoggerFactory' import ConsoleLogReporter from '../src/ConsoleLogReporter' import LogReporter from '../src/LogReporter' import JSONLineReporter from '../src/JSONLineReporter' import FileLogReporter from '../src/FileLogReporter' +import StreamReporter from '../src/StreamReporter' import BufferedReporter from '../src/BufferedReporter' +import RingBufferReporter from '../src/RingBufferReporter' +import LevelFilterReporter from '../src/LevelFilterReporter' +import ScopeFilterReporter from '../src/ScopeFilterReporter' +import RedactionReporter from '../src/RedactionReporter' +import SamplingReporter from '../src/SamplingReporter' +import FanoutReporter, { MultiReporter } from '../src/FanoutReporter' +import HTTPReporter, { type HTTPTransport } from '../src/HTTPReporter' +import OTLPReporter, { type OTLPTransport } from '../src/OTLPReporter' +import BeaconReporter from '../src/BeaconReporter' +import IndexedDBReporter from '../src/IndexedDBReporter' +import { textFormatter, jsonFormatter, logfmtFormatter } from '../src/Formatter' +import AsyncContext from '../src/AsyncContext' +import ContextualReporter from '../src/ContextualReporter' +import MemoryReporter from '../src/MemoryReporter' + +// 2026-01-01T00:00:00.000Z in epoch ms +const T0 = 1767225600000 +// 2026-01-01T00:00:01.000Z in epoch ms +const T1 = 1767225601000 describe('Level', () => { it('has all expected levels', () => { @@ -36,6 +56,27 @@ describe('Level', () => { it('getLevel returns silent for unknown order', () => { expect(getLevel(999)).toBe('silent') }) + + it('isKnownLevel identifies valid level tokens', () => { + expect(isKnownLevel('silent')).toBe(true) + expect(isKnownLevel('error')).toBe(true) + expect(isKnownLevel('warning')).toBe(true) + expect(isKnownLevel('info')).toBe(true) + expect(isKnownLevel('debug')).toBe(true) + expect(isKnownLevel('verbose')).toBe(true) + }) + + it('isKnownLevel rejects unknown strings', () => { + expect(isKnownLevel('trace')).toBe(false) + expect(isKnownLevel('warn')).toBe(false) + expect(isKnownLevel('trase')).toBe(false) + expect(isKnownLevel('')).toBe(false) + expect(isKnownLevel('VERBOSE')).toBe(false) + }) + + it('KNOWN_LEVELS contains exactly the six valid level tokens', () => { + expect([...KNOWN_LEVELS].sort()).toEqual(['debug', 'error', 'info', 'silent', 'verbose', 'warning']) + }) }) describe('LoggerFactory', () => { @@ -60,11 +101,31 @@ describe('LoggerFactory', () => { expect(factory.level).toBe('verbose') }) + it('throws RangeError when an invalid level is assigned', () => { + const factory = new LoggerFactory([]) + expect(() => { (factory as any).level = 'warn' }).toThrow(RangeError) + expect(() => { (factory as any).level = 'trace' }).toThrow(RangeError) + expect(() => { (factory as any).level = '' }).toThrow(RangeError) + expect(() => { (factory as any).level = 'VERBOSE' }).toThrow(RangeError) + }) + + it('drops unknown level tokens even when factory level is silent', () => { + const messages: string[] = [] + class DummyReporter extends LogReporter { + log(level: string): void { messages.push(level) } + } + const factory = new LoggerFactory([new DummyReporter()]) + factory.level = 'silent' + const logger = factory.getLogger('test') + logger.log('trace' as any, 'should be dropped') + expect(messages).toHaveLength(0) + }) + it('filters messages below configured level', () => { const messages: { level: string; msgs: any[] }[] = [] class DummyReporter extends LogReporter { - log(level: string, scope: string, time: string, msgs: any[]): void { + log(level: string, scope: string, time: number, _fields: any, msgs: any[]): void { messages.push({ level, msgs }) } } @@ -84,18 +145,275 @@ describe('LoggerFactory', () => { }) }) +describe('LoggerFactory — flush / close lifecycle', () => { + it('flush() calls flush() on all reporters including dynamically added ones', () => { + const flushed: string[] = [] + class F extends LogReporter { + constructor(private id: string) { super() } + log(): void {} + flush(): void { flushed.push(this.id) } + } + const factory = new LoggerFactory([new F('a')]) + factory.addReporter(new F('b')) + factory.flush() + expect(flushed).toEqual(['a', 'b']) + }) + + it('flush() does not call flush() on removed reporters', () => { + const flushed: string[] = [] + class F extends LogReporter { + constructor(private id: string) { super() } + log(): void {} + flush(): void { flushed.push(this.id) } + } + const r = new F('x') + const factory = new LoggerFactory([r]) + factory.removeReporter(r) + factory.flush() + expect(flushed).toHaveLength(0) + }) + + it('flush() isolates reporter errors', () => { + class Throws extends LogReporter { + log(): void {} + flush(): void { throw new Error('flush failure') } + } + const factory = new LoggerFactory([new Throws()]) + expect(() => factory.flush()).not.toThrow() + }) + + it('close() calls close() on all reporters including dynamically added ones', async () => { + const closed: string[] = [] + class C extends LogReporter { + constructor(private id: string) { super() } + log(): void {} + close(): void { closed.push(this.id) } + } + const factory = new LoggerFactory([new C('a')]) + factory.addReporter(new C('b')) + await factory.close() + expect(closed).toEqual(['a', 'b']) + }) + + it('close() awaits async close() on reporters', async () => { + let resolved = false + class AsyncC extends LogReporter { + log(): void {} + close(): Promise { + return new Promise(r => setTimeout(() => { resolved = true; r() }, 10)) + } + } + const factory = new LoggerFactory([new AsyncC()]) + await factory.close() + expect(resolved).toBe(true) + }) + + it('close() does not call close() on removed reporters', async () => { + const closed: string[] = [] + class C extends LogReporter { + constructor(private id: string) { super() } + log(): void {} + close(): void { closed.push(this.id) } + } + const r = new C('gone') + const factory = new LoggerFactory([r, new C('kept')]) + factory.removeReporter(r) + await factory.close() + expect(closed).toEqual(['kept']) + }) + + it('close() isolates reporter errors', async () => { + class Throws extends LogReporter { + log(): void {} + close(): void { throw new Error('close failure') } + } + const factory = new LoggerFactory([new Throws()]) + await expect(factory.close()).resolves.toBeUndefined() + }) +}) + +describe('LoggerFactory — close() drains BufferedReporter', () => { + it('factory.close() drains buffered entries to the inner reporter', async () => { + const received: string[] = [] + class Inner extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { received.push(msgs[0]) } + } + const buf = new BufferedReporter(new Inner(), { maxSize: 100, flushInterval: 99999 }) + const factory = new LoggerFactory([buf]) + factory.getLogger('test').info('alpha') + factory.getLogger('test').info('beta') + expect(received).toHaveLength(0) + await factory.close() + expect(received).toEqual(['alpha', 'beta']) + }) + + it('factory.flush() drains buffered entries to the inner reporter', () => { + const received: string[] = [] + class Inner extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { received.push(msgs[0]) } + } + const buf = new BufferedReporter(new Inner(), { maxSize: 100, flushInterval: 99999 }) + const factory = new LoggerFactory([buf]) + factory.getLogger('test').info('gamma') + expect(received).toHaveLength(0) + factory.flush() + expect(received).toEqual(['gamma']) + }) + + it('factory.close() drains multiple BufferedReporters', async () => { + const drainedA: string[] = [] + const drainedB: string[] = [] + class A extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { drainedA.push(msgs[0]) } } + class B extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { drainedB.push(msgs[0]) } } + const bufA = new BufferedReporter(new A(), { maxSize: 100, flushInterval: 99999 }) + const bufB = new BufferedReporter(new B(), { maxSize: 100, flushInterval: 99999 }) + const factory = new LoggerFactory([bufA, bufB]) + factory.getLogger('t').info('msg') + expect(drainedA).toHaveLength(0) + expect(drainedB).toHaveLength(0) + await factory.close() + expect(drainedA).toEqual(['msg']) + expect(drainedB).toEqual(['msg']) + }) +}) + +describe('LoggerFactory — addReporter / removeReporter', () => { + it('addReporter causes the reporter to receive subsequent logs', () => { + const received: string[] = [] + class R extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { received.push(msgs[0]) } + } + const factory = new LoggerFactory([]) + const logger = factory.getLogger('test') + const reporter = new R() + factory.addReporter(reporter) + logger.info('after-add') + expect(received).toEqual(['after-add']) + }) + + it('added reporter does not receive logs emitted before addReporter', () => { + const received: string[] = [] + class R extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { received.push(msgs[0]) } + } + const factory = new LoggerFactory([]) + const logger = factory.getLogger('test') + logger.info('before-add') + factory.addReporter(new R()) + expect(received).toHaveLength(0) + }) + + it('removeReporter stops the reporter from receiving subsequent logs', () => { + const received: string[] = [] + class R extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { received.push(msgs[0]) } + } + const reporter = new R() + const factory = new LoggerFactory([reporter]) + const logger = factory.getLogger('test') + logger.info('before-remove') + factory.removeReporter(reporter) + logger.info('after-remove') + expect(received).toEqual(['before-remove']) + }) + + it('removeReporter is a no-op for an unregistered reporter', () => { + const factory = new LoggerFactory([]) + class R extends LogReporter { log(): void {} } + expect(() => factory.removeReporter(new R())).not.toThrow() + }) + + it('multiple reporters can be added and each receives logs', () => { + const a: string[] = [] + const b: string[] = [] + class A extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { a.push(msgs[0]) } } + class B extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { b.push(msgs[0]) } } + const factory = new LoggerFactory([]) + factory.addReporter(new A()) + factory.addReporter(new B()) + factory.getLogger('t').info('msg') + expect(a).toEqual(['msg']) + expect(b).toEqual(['msg']) + }) +}) + +describe('LoggerFactory — clock injection', () => { + it('timestamp delivered to reporter is a number (epoch ms)', () => { + const received: number[] = [] + class R extends LogReporter { + log(_l: any, _s: any, time: number): void { received.push(time) } + } + const factory = new LoggerFactory([new R()]) + factory.getLogger('t').info('msg') + expect(typeof received[0]).toBe('number') + expect(received[0]).toBeGreaterThan(0) + }) + + it('injected clock determines the timestamp passed to reporters', () => { + const received: number[] = [] + class R extends LogReporter { + log(_l: any, _s: any, time: number): void { received.push(time) } + } + const factory = new LoggerFactory([new R()], () => 1234567890000) + factory.getLogger('t').info('msg') + expect(received[0]).toBe(1234567890000) + }) + + it('clock is not called for disabled levels', () => { + let calls = 0 + const factory = new LoggerFactory([], () => { calls++; return 1000 }) + factory.level = 'info' + factory.getLogger('t').debug('disabled') + expect(calls).toBe(0) + }) + + it('clock is called once per enabled log call', () => { + let calls = 0 + const factory = new LoggerFactory([], () => { calls++; return 1000 }) + factory.level = 'debug' + factory.getLogger('t').debug('enabled') + expect(calls).toBe(1) + }) + + it('withContext child uses the same injected clock', () => { + const received: number[] = [] + class R extends LogReporter { + log(_l: any, _s: any, time: number): void { received.push(time) } + } + const factory = new LoggerFactory([new R()], () => 9999) + const log = factory.getLogger('t') + const child = log.withContext({ x: 1 }) + child.info('msg') + expect(received[0]).toBe(9999) + }) + + it('fixed clock makes timestamps deterministic across multiple calls', () => { + const received: number[] = [] + class R extends LogReporter { + log(_l: any, _s: any, time: number): void { received.push(time) } + } + const factory = new LoggerFactory([new R()], () => 42) + const log = factory.getLogger('t') + log.info('a') + log.info('b') + log.warning('c') + expect(received).toEqual([42, 42, 42]) + }) +}) + describe('Logger', () => { it('passes scope, time, and args through dispatch fn', () => { const captures: any[] = [] - const logger = new Logger('billing', (level, scope, time, messages) => { - captures.push({ level, scope, time, messages }) + const logger = new Logger('billing', (level, scope, time, fields, messages) => { + captures.push({ level, scope, time, fields, messages }) }) logger.info('hello', 42) expect(captures).toHaveLength(1) expect(captures[0].level).toBe('info') expect(captures[0].scope).toBe('billing') + expect(captures[0].fields).toEqual({}) expect(captures[0].messages).toEqual(['hello', 42]) - expect(typeof captures[0].time).toBe('string') + expect(typeof captures[0].time).toBe('number') }) it('exposes a method per level', () => { @@ -109,12 +427,20 @@ describe('Logger', () => { expect(captures).toEqual(['error', 'warning', 'info', 'debug', 'verbose']) }) - it('log() accepts arbitrary level token', () => { + it('log() dispatches known level tokens', () => { const captures: string[] = [] const logger = new Logger('s', (level) => { captures.push(level) }) logger.log('verbose', 'msg') expect(captures).toEqual(['verbose']) }) + + it('log() drops unknown level tokens even without a factory isEnabledFn', () => { + const captures: string[] = [] + const logger = new Logger('s', (level) => { captures.push(level) }) + logger.log('trace' as any, 'should be dropped') + logger.log('warn' as any, 'should be dropped') + expect(captures).toHaveLength(0) + }) }) describe('Logger.setLevel (per-logger override)', () => { @@ -185,41 +511,41 @@ describe('Logger.setLevel (per-logger override)', () => { }) describe('Logger.withContext (structured context binding)', () => { - it('returns a child logger that injects context as the first message arg', () => { - const captured: any[][] = [] - class R extends LogReporter { log(_l: any, _s: any, _t: any, msgs: any[]) { captured.push(msgs) } } + it('threads context as top-level fields, not as first message', () => { + const captured: { fields: any, msgs: any[] }[] = [] + class R extends LogReporter { log(_l: any, _s: any, _t: any, fields: any, msgs: any[]) { captured.push({ fields, msgs }) } } const factory = new LoggerFactory([new R()]) const log = factory.getLogger('svc') const req = log.withContext({ requestId: 'r1' }) req.info('start') req.info('done', 42) - expect(captured).toEqual([ - [{ requestId: 'r1' }, 'start'], - [{ requestId: 'r1' }, 'done', 42] - ]) + expect(captured[0].fields).toEqual({ requestId: 'r1' }) + expect(captured[0].msgs).toEqual(['start']) + expect(captured[1].fields).toEqual({ requestId: 'r1' }) + expect(captured[1].msgs).toEqual(['done', 42]) }) it('does not mutate the parent logger', () => { - const captured: any[][] = [] - class R extends LogReporter { log(_l: any, _s: any, _t: any, msgs: any[]) { captured.push(msgs) } } + const captured: { fields: any, msgs: any[] }[] = [] + class R extends LogReporter { log(_l: any, _s: any, _t: any, fields: any, msgs: any[]) { captured.push({ fields, msgs }) } } const factory = new LoggerFactory([new R()]) const parent = factory.getLogger('svc') parent.withContext({ x: 1 }) parent.info('hi') - expect(captured).toEqual([['hi']]) + expect(captured[0].fields).toEqual({}) + expect(captured[0].msgs).toEqual(['hi']) }) it('nested withContext merges context, child wins on key conflict', () => { - const captured: any[][] = [] - class R extends LogReporter { log(_l: any, _s: any, _t: any, msgs: any[]) { captured.push(msgs) } } + const captured: { fields: any, msgs: any[] }[] = [] + class R extends LogReporter { log(_l: any, _s: any, _t: any, fields: any, msgs: any[]) { captured.push({ fields, msgs }) } } const factory = new LoggerFactory([new R()]) const log = factory.getLogger('svc') const a = log.withContext({ requestId: 'r1', userId: 7 }) const b = a.withContext({ userId: 9, sessionId: 's' }) b.info('event') - expect(captured).toEqual([ - [{ requestId: 'r1', userId: 9, sessionId: 's' }, 'event'] - ]) + expect(captured[0].fields).toEqual({ requestId: 'r1', userId: 9, sessionId: 's' }) + expect(captured[0].msgs).toEqual(['event']) }) it('inherits parent level override', () => { @@ -235,6 +561,143 @@ describe('Logger.withContext (structured context binding)', () => { }) }) +describe('Logger.isEnabled (public guard predicate)', () => { + it('returns true for all levels when no factory predicate is wired', () => { + const log = new Logger('s', () => {}) + expect(log.isEnabled('error')).toBe(true) + expect(log.isEnabled('debug')).toBe(true) + expect(log.isEnabled('verbose')).toBe(true) + }) + + it('reflects factory level threshold', () => { + const factory = new LoggerFactory([]) + factory.level = 'warning' + const log = factory.getLogger('x') + expect(log.isEnabled('error')).toBe(true) + expect(log.isEnabled('warning')).toBe(true) + expect(log.isEnabled('info')).toBe(false) + expect(log.isEnabled('debug')).toBe(false) + expect(log.isEnabled('verbose')).toBe(false) + }) + + it('respects per-logger level override', () => { + const factory = new LoggerFactory([]) + factory.level = 'warning' + const log = factory.getLogger('y') + log.setLevel('verbose') + expect(log.isEnabled('debug')).toBe(true) + expect(log.isEnabled('verbose')).toBe(true) + }) + + it('withContext child inherits predicate', () => { + const factory = new LoggerFactory([]) + factory.level = 'info' + const log = factory.getLogger('z') + const child = log.withContext({ requestId: 'r1' }) + expect(child.isEnabled('info')).toBe(true) + expect(child.isEnabled('debug')).toBe(false) + }) + +}) + +describe('Logger — early return for disabled levels', () => { + it('does not invoke the clock or the dispatch fn for a disabled level', () => { + let clockCalls = 0 + const factory = new LoggerFactory([], () => { clockCalls++; return 1000 }) + factory.level = 'info' + const log = factory.getLogger('perf') + const child = log.withContext({ requestId: 'r1' }) + child.debug('expensive payload') + expect(clockCalls).toBe(0) + }) + + it('does not invoke dispatch fn for a disabled level', () => { + const dispatched: string[] = [] + const factory = new LoggerFactory([]) + factory.level = 'info' + const log = factory.getLogger('perf2') + ;(log as any).logMessageFn = (level: string) => dispatched.push(level) + log.debug('should be dropped') + expect(dispatched).toHaveLength(0) + }) + + it('invokes the clock exactly once for an enabled level', () => { + let clockCalls = 0 + const factory = new LoggerFactory([], () => { clockCalls++; return 1000 }) + factory.level = 'debug' + const log = factory.getLogger('enabled') + log.debug('this should pass') + expect(clockCalls).toBe(1) + }) + + it('short-circuits context array allocation when level is disabled', () => { + const received: { fields: any, msgs: any[] }[] = [] + class R extends LogReporter { log(_l: any, _s: any, _t: any, fields: any, msgs: any[]) { received.push({ fields, msgs }) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'info' + const log = factory.getLogger('ctx') + const child = log.withContext({ userId: 42 }) + child.debug('nope') + expect(received).toHaveLength(0) + child.info('yes') + expect(received).toHaveLength(1) + expect(received[0].fields).toEqual({ userId: 42 }) + expect(received[0].msgs).toEqual(['yes']) + }) +}) + +describe('Logger — lazy thunk evaluation', () => { + it('never calls a thunk arg when the level is disabled', () => { + const factory = new LoggerFactory([]) + factory.level = 'info' + const log = factory.getLogger('thunk') + let called = false + log.debug(() => { called = true; return 'expensive' }) + expect(called).toBe(false) + }) + + it('calls a thunk arg when the level is enabled', () => { + const received: any[][] = [] + class R extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { received.push(msgs) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'debug' + const log = factory.getLogger('thunk') + log.debug(() => 'lazy-value') + expect(received).toHaveLength(1) + expect(received[0]).toEqual(['lazy-value']) + }) + + it('evaluates each thunk in a mixed args list', () => { + const received: any[][] = [] + class R extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { received.push(msgs) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'debug' + const log = factory.getLogger('thunk') + log.debug('prefix', () => ({ id: 99 }), 'suffix') + expect(received[0]).toEqual(['prefix', { id: 99 }, 'suffix']) + }) + + it('does not call any thunk in a mixed list when the level is disabled', () => { + const factory = new LoggerFactory([]) + factory.level = 'info' + const log = factory.getLogger('thunk') + let calls = 0 + log.debug(() => { calls++; return 'a' }, () => { calls++; return 'b' }) + expect(calls).toBe(0) + }) + + it('thunk result is threaded to fields and messages separately when context is set', () => { + const received: { fields: any, msgs: any[] }[] = [] + class R extends LogReporter { log(_l: any, _s: any, _t: any, fields: any, msgs: any[]) { received.push({ fields, msgs }) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'debug' + const log = factory.getLogger('thunk').withContext({ req: 'r1' }) + log.debug(() => 'lazy') + expect(received[0].fields).toEqual({ req: 'r1' }) + expect(received[0].msgs).toEqual(['lazy']) + }) +}) + describe('ConsoleLogReporter', () => { it('can be instantiated', () => { const reporter = new ConsoleLogReporter() @@ -245,7 +708,7 @@ describe('ConsoleLogReporter', () => { it('routes error level to console.error', () => { const reporter = new ConsoleLogReporter() const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - reporter.log('error', 'svc', '2026-01-01T00:00:00Z', ['boom']) + reporter.log('error', 'svc', T0, {}, ['boom']) expect(spy).toHaveBeenCalledTimes(1) const call = spy.mock.calls[0].join(' ') expect(call).toContain('ERROR') @@ -257,7 +720,7 @@ describe('ConsoleLogReporter', () => { it('routes warning level to console.warn', () => { const reporter = new ConsoleLogReporter() const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - reporter.log('warning', 'svc', 't', ['heads up']) + reporter.log('warning', 'svc', T0, {}, ['heads up']) expect(spy).toHaveBeenCalledTimes(1) spy.mockRestore() }) @@ -265,33 +728,183 @@ describe('ConsoleLogReporter', () => { it('routes info/debug/verbose to console.log', () => { const reporter = new ConsoleLogReporter() const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) - reporter.log('info', 's', 't', ['x']) - reporter.log('debug', 's', 't', ['x']) - reporter.log('verbose', 's', 't', ['x']) + reporter.log('info', 's', T0, {}, ['x']) + reporter.log('debug', 's', T0, {}, ['x']) + reporter.log('verbose', 's', T0, {}, ['x']) expect(spy).toHaveBeenCalledTimes(3) spy.mockRestore() }) + + it('{ color: false } emits no ANSI escape sequences', () => { + const reporter = new ConsoleLogReporter({ color: false }) + const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const spyWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const spyLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('error', 'svc', T0, {}, ['msg']) + reporter.log('warning', 'svc', T0, {}, ['msg']) + reporter.log('info', 'svc', T0, {}, ['msg']) + for (const spy of [spyError, spyWarn, spyLog]) { + for (const call of spy.mock.calls) { + expect(call.join(' ')).not.toMatch(/\x1b\[/) + } + spy.mockRestore() + } + }) + + it('NO_COLOR env var disables color auto-detection', () => { + const prev = process.env.NO_COLOR + process.env.NO_COLOR = '' + try { + const reporter = new ConsoleLogReporter() + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 's', T0, {}, ['msg']) + const call = spy.mock.calls[0] + expect(call.join(' ')).not.toMatch(/\x1b\[/) + expect(call[0]).not.toContain('%c') + spy.mockRestore() + } finally { + if (prev === undefined) delete process.env.NO_COLOR + else process.env.NO_COLOR = prev + } + }) + + it('{ timestamp: false } omits time from the default prefix', () => { + const reporter = new ConsoleLogReporter({ color: false, timestamp: false }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 'svc', T0, {}, ['msg']) + const call = spy.mock.calls[0].join(' ') + expect(call).not.toContain('2026-01-01T00:00:00') + expect(call).toContain('INFO') + expect(call).toContain('svc') + spy.mockRestore() + }) + + it('{ timestamp: true } (default) includes ISO time in the prefix', () => { + const reporter = new ConsoleLogReporter({ color: false }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 'svc', T0, {}, ['msg']) + const call = spy.mock.calls[0].join(' ') + expect(call).toContain('2026-01-01T00:00:00.000Z') + spy.mockRestore() + }) + + it('custom string prefix replaces the default prefix', () => { + const reporter = new ConsoleLogReporter({ color: false, prefix: '[MY-APP]' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 'svc', T0, {}, ['msg']) + const call = spy.mock.calls[0].join(' ') + expect(call).toContain('[MY-APP]') + expect(call).not.toContain('INFO') + expect(call).not.toContain('svc') + spy.mockRestore() + }) + + it('prefix function receives level/scope/time (number) and its return is used', () => { + const reporter = new ConsoleLogReporter({ + color: false, + prefix: (level, scope, time) => `${level}|${scope}|${new Date(time).toISOString()}` + }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 'svc', T0, {}, ['msg']) + const call = spy.mock.calls[0].join(' ') + expect(call).toContain('info|svc|2026-01-01T00:00:00.000Z') + spy.mockRestore() + }) + + it('{ objects: "pretty" } serializes plain objects to indented JSON strings', () => { + const reporter = new ConsoleLogReporter({ color: false, objects: 'pretty' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 's', T0, {}, [{ key: 'value' }]) + const objectArg = spy.mock.calls[0][1] + expect(typeof objectArg).toBe('string') + expect(objectArg).toContain('"key"') + expect(objectArg).toContain('"value"') + spy.mockRestore() + }) + + it('{ objects: "pretty" } serializes Error instances to a string', () => { + const reporter = new ConsoleLogReporter({ color: false, objects: 'pretty' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 's', T0, {}, [new Error('boom')]) + const objectArg = spy.mock.calls[0][1] + expect(typeof objectArg).toBe('string') + expect(objectArg).toContain('boom') + spy.mockRestore() + }) + + it('{ objects: "pretty" } passes primitives through unchanged', () => { + const reporter = new ConsoleLogReporter({ color: false, objects: 'pretty' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 's', T0, {}, ['hello', 42, true, null]) + const args = spy.mock.calls[0].slice(1) + expect(args).toEqual(['hello', 42, true, null]) + spy.mockRestore() + }) + + it('{ objects: "compact" } (default) passes objects through unchanged', () => { + const reporter = new ConsoleLogReporter({ color: false }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const obj = { key: 'value' } + reporter.log('info', 's', T0, {}, [obj]) + const objectArg = spy.mock.calls[0][1] + expect(objectArg).toBe(obj) + spy.mockRestore() + }) }) describe('JSONLineReporter', () => { - it('emits a JSON line with level/scope/time/messages', () => { + it('emits a JSON line with level/scope/time (epoch ms)/messages', () => { const lines: string[] = [] const reporter = new JSONLineReporter({ write: line => lines.push(line) }) - reporter.log('info', 'auth', '2026-01-01T00:00:00Z', ['ok', { id: 7 }]) + reporter.log('info', 'auth', T0, {}, ['ok', { id: 7 }]) expect(lines).toHaveLength(1) const parsed = JSON.parse(lines[0]) expect(parsed).toEqual({ level: 'info', scope: 'auth', - time: '2026-01-01T00:00:00Z', + time: T0, messages: ['ok', { id: 7 }] }) }) + it('spreads context fields as top-level keys alongside level/scope/time/messages', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ write: line => lines.push(line) }) + reporter.log('info', 'svc', T0, { requestId: 'r1', userId: 42 }, ['start']) + expect(lines).toHaveLength(1) + const parsed = JSON.parse(lines[0]) + expect(parsed.requestId).toBe('r1') + expect(parsed.userId).toBe(42) + expect(parsed.messages).toEqual(['start']) + expect(parsed.level).toBe('info') + expect(parsed.scope).toBe('svc') + }) + + it('context fields appear at top level via factory pipeline (requestId top-level field)', () => { + const lines: string[] = [] + const factory = new LoggerFactory([new JSONLineReporter({ write: line => lines.push(line) })]) + const log = factory.getLogger('api') + const req = log.withContext({ requestId: 'req-123' }) + req.info('start') + expect(lines).toHaveLength(1) + const parsed = JSON.parse(lines[0]) + expect(parsed.requestId).toBe('req-123') + expect(parsed.messages).toEqual(['start']) + }) + + it('time in JSON record is a number (epoch ms)', () => { + const lines: string[] = [] + const factory = new LoggerFactory([new JSONLineReporter({ write: line => lines.push(line) })], () => T0) + factory.getLogger('t').info('msg') + const parsed = JSON.parse(lines[0]) + expect(typeof parsed.time).toBe('number') + expect(parsed.time).toBe(T0) + }) + it('serializes Error instances with stack', () => { const lines: string[] = [] const reporter = new JSONLineReporter({ write: line => lines.push(line) }) - reporter.log('error', 's', 't', [new Error('boom')]) + reporter.log('error', 's', T0, {}, [new Error('boom')]) const parsed = JSON.parse(lines[0]) expect(parsed.messages[0].name).toBe('Error') expect(parsed.messages[0].message).toBe('boom') @@ -304,21 +917,98 @@ describe('JSONLineReporter', () => { write: line => lines.push(line), extra: { service: 'api', region: 'eu' } }) - reporter.log('info', 's', 't', ['ok']) + reporter.log('info', 's', T0, {}, ['ok']) const parsed = JSON.parse(lines[0]) expect(parsed.service).toBe('api') expect(parsed.region).toBe('eu') }) - it('falls back when message contains a circular ref', () => { + it('context fields override extra but not level/scope/time/messages', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ + write: line => lines.push(line), + extra: { service: 'api', env: 'prod' } + }) + reporter.log('info', 's', T0, { env: 'staging', requestId: 'r1' }, ['msg']) + const parsed = JSON.parse(lines[0]) + expect(parsed.env).toBe('staging') + expect(parsed.requestId).toBe('r1') + expect(parsed.service).toBe('api') + }) + + it('replaces a circular ref field with [Circular] and keeps siblings', () => { const lines: string[] = [] const reporter = new JSONLineReporter({ write: line => lines.push(line) }) const cyc: any = {} cyc.self = cyc - reporter.log('info', 's', 't', [cyc]) + reporter.log('info', 's', T0, {}, ['ok', cyc]) + expect(lines).toHaveLength(1) + const parsed = JSON.parse(lines[0]) + expect(parsed.messages[0]).toBe('ok') + expect(parsed.messages[1]).toEqual({ self: '[Circular]' }) + }) + + it('serializes a nested Error with name/message/stack', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ write: line => lines.push(line) }) + reporter.log('error', 's', T0, {}, [{ err: new Error('x') }]) + const parsed = JSON.parse(lines[0]) + expect(parsed.messages[0].err.name).toBe('Error') + expect(parsed.messages[0].err.message).toBe('x') + expect(typeof parsed.messages[0].err.stack).toBe('string') + }) + + it('serializes BigInt messages as strings', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ write: line => lines.push(line) }) + reporter.log('info', 's', T0, {}, [42n]) + const parsed = JSON.parse(lines[0]) + expect(parsed.messages[0]).toBe('42') + }) + + it('preserves good fields in an object that also contains a bigint field', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ write: line => lines.push(line) }) + reporter.log('info', 's', T0, {}, [{ label: 'counter', value: 9007199254740993n, unit: 'ops' }]) + expect(lines).toHaveLength(1) + const parsed = JSON.parse(lines[0]) + expect(parsed.messages[0]).toEqual({ label: 'counter', value: '9007199254740993', unit: 'ops' }) + }) + + it('preserves good fields in an object that also contains a circular ref field', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ write: line => lines.push(line) }) + const obj: any = { name: 'node', status: 'ok', count: 3 } + obj.self = obj + reporter.log('info', 's', T0, {}, [obj]) + expect(lines).toHaveLength(1) + const parsed = JSON.parse(lines[0]) + expect(parsed.messages[0].name).toBe('node') + expect(parsed.messages[0].status).toBe('ok') + expect(parsed.messages[0].count).toBe(3) + expect(parsed.messages[0].self).toBe('[Circular]') + }) + + it('handles deeply nested mixed good/bad fields without losing the surrounding record', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ write: line => lines.push(line) }) + const inner: any = { x: 1 } + inner.loop = inner + reporter.log('info', 's', T0, {}, [ + 'prefix', + { fine: true, nested: { ok: 'yes', circ: inner, big: 42n }, after: 'end' }, + 'suffix' + ]) expect(lines).toHaveLength(1) const parsed = JSON.parse(lines[0]) - expect(parsed.messages).toEqual(['']) + expect(parsed.messages[0]).toBe('prefix') + expect(parsed.messages[1].fine).toBe(true) + expect(parsed.messages[1].nested.ok).toBe('yes') + expect(parsed.messages[1].nested.circ.x).toBe(1) + expect(parsed.messages[1].nested.circ.loop).toBe('[Circular]') + expect(parsed.messages[1].nested.big).toBe('42') + expect(parsed.messages[1].after).toBe('end') + expect(parsed.messages[2]).toBe('suffix') }) }) @@ -327,10 +1017,10 @@ describe('BufferedReporter', () => { const captured: string[][] = [] class R extends LogReporter { log(level: any) { captured.push([level]) } } const buf = new BufferedReporter(new R(), { maxSize: 3, flushInterval: 0 }) - buf.log('info', 's', 't', ['a']) - buf.log('info', 's', 't', ['b']) + buf.log('info', 's', T0, {}, ['a']) + buf.log('info', 's', T0, {}, ['b']) expect(captured).toHaveLength(0) - buf.log('info', 's', 't', ['c']) + buf.log('info', 's', T0, {}, ['c']) expect(captured).toHaveLength(3) }) @@ -339,8 +1029,8 @@ describe('BufferedReporter', () => { const captured: number[] = [] class R extends LogReporter { log() { captured.push(1) } } const buf = new BufferedReporter(new R(), { maxSize: 100, flushInterval: 500 }) - buf.log('info', 's', 't', ['a']) - buf.log('info', 's', 't', ['b']) + buf.log('info', 's', T0, {}, ['a']) + buf.log('info', 's', T0, {}, ['b']) expect(captured).toHaveLength(0) vi.advanceTimersByTime(500) expect(captured).toHaveLength(2) @@ -352,7 +1042,7 @@ describe('BufferedReporter', () => { const captured: number[] = [] class R extends LogReporter { log() { captured.push(1) } } const buf = new BufferedReporter(new R(), { maxSize: 100, flushInterval: 1000 }) - buf.log('info', 's', 't', ['a']) + buf.log('info', 's', T0, {}, ['a']) buf.flush() expect(captured).toHaveLength(1) vi.advanceTimersByTime(2000) @@ -365,8 +1055,8 @@ describe('BufferedReporter', () => { const captured: number[] = [] class R extends LogReporter { log() { captured.push(1) } } const buf = new BufferedReporter(new R(), { maxSize: 100, flushInterval: 1000 }) - buf.log('info', 's', 't', ['a']) - buf.log('info', 's', 't', ['b']) + buf.log('info', 's', T0, {}, ['a']) + buf.log('info', 's', T0, {}, ['b']) buf.close() expect(captured).toHaveLength(2) vi.advanceTimersByTime(2000) @@ -381,26 +1071,58 @@ describe('BufferedReporter', () => { flushInterval: 0, onFlush: entries => batches.push(entries) }) - buf.log('info', 's', 't', ['a']) - buf.log('warning', 's', 't', ['b']) + buf.log('info', 's', T0, {}, ['a']) + buf.log('warning', 's', T0, {}, ['b']) expect(batches).toHaveLength(1) expect(batches[0]).toHaveLength(2) expect(batches[0][0].level).toBe('info') expect(batches[0][1].level).toBe('warning') }) - it('throws when neither inner reporter nor onFlush is provided', () => { - expect(() => new BufferedReporter(null, {})).toThrow(/inner reporter or an onFlush/) - }) - - it('size() reports buffered entry count', () => { - const buf = new BufferedReporter(null, { onFlush: () => {}, maxSize: 100, flushInterval: 0 }) - expect(buf.size()).toBe(0) - buf.log('info', 's', 't', ['a']) - buf.log('info', 's', 't', ['b']) - expect(buf.size()).toBe(2) - buf.flush() - expect(buf.size()).toBe(0) + it('LogEntry.time is a number (epoch ms)', () => { + const batches: any[][] = [] + const buf = new BufferedReporter(null, { + maxSize: 1, + flushInterval: 0, + onFlush: entries => batches.push(entries) + }) + buf.log('info', 's', T0, {}, ['a']) + expect(typeof batches[0][0].time).toBe('number') + expect(batches[0][0].time).toBe(T0) + }) + + it('when both inner and onFlush are provided, both receive the flushed entries', () => { + const innerReceived: string[] = [] + class R extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { innerReceived.push(msgs[0]) } + } + const batches: any[][] = [] + const buf = new BufferedReporter(new R(), { + maxSize: 2, + flushInterval: 0, + onFlush: entries => batches.push(entries) + }) + buf.log('info', 's', T0, {}, ['x']) + buf.log('info', 's', T0, {}, ['y']) + // onFlush received the batch + expect(batches).toHaveLength(1) + expect(batches[0]).toHaveLength(2) + // inner also received each entry individually + expect(innerReceived).toEqual(['x', 'y']) + }) + + it('throws when neither inner reporter nor onFlush is provided', () => { + expect(() => new BufferedReporter(null, {})).toThrow(/inner reporter or an onFlush/) + }) + + it('size() reports buffered entry count', () => { + const buf = new BufferedReporter(null, { onFlush: () => {}, maxSize: 100, flushInterval: 0 }) + expect(buf.size()).toBe(0) + buf.log('info', 's', T0, {}, ['a']) + buf.log('info', 's', T0, {}, ['b']) + expect(buf.size()).toBe(2) + buf.flush() + expect(buf.size()).toBe(0) }) it('flush on empty buffer is a no-op', () => { @@ -409,6 +1131,124 @@ describe('BufferedReporter', () => { buf.flush() expect(calls).toBe(0) }) + + it('flush() is called on process.beforeExit', () => { + const buf = new BufferedReporter(null, { onFlush: () => {}, maxSize: 100, flushInterval: 0 }) + buf.log('info', 's', T0, {}, ['pending']) + const flushSpy = vi.spyOn(buf, 'flush') + process.emit('beforeExit', 0) + expect(flushSpy).toHaveBeenCalledTimes(1) + flushSpy.mockRestore() + }) + + it('flush() is called on window pagehide', () => { + const originalWindow = (globalThis as any).window + const listeners: Array<() => void> = [] + ;(globalThis as any).window = { + addEventListener: (event: string, fn: () => void, _opts?: any) => { + if (event === 'pagehide') listeners.push(fn) + } + } + try { + const received: string[] = [] + const buf = new BufferedReporter(null, { + onFlush: entries => entries.forEach(e => received.push(e.messages[0])), + maxSize: 100, + flushInterval: 0 + }) + buf.log('info', 's', T0, {}, ['queued']) + expect(received).toHaveLength(0) + listeners.forEach(fn => fn()) + expect(received).toEqual(['queued']) + } finally { + if (originalWindow === undefined) delete (globalThis as any).window + else (globalThis as any).window = originalWindow + } + }) +}) + +describe('LoggerFactory — reporter isolation', () => { + it('does not throw when a reporter throws', () => { + class ThrowingReporter extends LogReporter { + log(): void { throw new Error('reporter failure') } + } + const factory = new LoggerFactory([new ThrowingReporter()]) + const logger = factory.getLogger('iso') + expect(() => logger.info('msg')).not.toThrow() + }) + + it('runs sibling reporters after a throwing reporter', () => { + const received: string[] = [] + class ThrowingReporter extends LogReporter { + log(): void { throw new Error('reporter failure') } + } + class GoodReporter extends LogReporter { + log(level: any, _s: any, _t: any, _f: any, msgs: any[]): void { received.push(msgs[0]) } + } + const factory = new LoggerFactory([new ThrowingReporter(), new GoodReporter()]) + const logger = factory.getLogger('iso') + logger.info('hello') + expect(received).toEqual(['hello']) + }) +}) + +describe('BufferedReporter — flush isolation', () => { + it('does not throw when onFlush throws', () => { + const buf = new BufferedReporter(null, { + maxSize: 2, + flushInterval: 0, + onFlush: () => { throw new Error('flush failure') } + }) + buf.log('info', 's', T0, {}, ['a']) + expect(() => buf.log('info', 's', T0, {}, ['b'])).not.toThrow() + }) + + it('does not throw when the inner reporter throws during flush', () => { + class ThrowingReporter extends LogReporter { + log(): void { throw new Error('inner failure') } + } + const buf = new BufferedReporter(new ThrowingReporter(), { maxSize: 2, flushInterval: 0 }) + buf.log('info', 's', T0, {}, ['a']) + expect(() => buf.log('info', 's', T0, {}, ['b'])).not.toThrow() + }) + + it('buffer is cleared even when onFlush throws', () => { + const buf = new BufferedReporter(null, { + maxSize: 2, + flushInterval: 0, + onFlush: () => { throw new Error('flush failure') } + }) + buf.log('info', 's', T0, {}, ['a']) + buf.log('info', 's', T0, {}, ['b']) + expect(buf.size()).toBe(0) + }) + + it('inner reporter still receives all entries when onFlush throws', () => { + const innerReceived: string[] = [] + class R extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { innerReceived.push(msgs[0]) } + } + const buf = new BufferedReporter(new R(), { + maxSize: 2, + flushInterval: 0, + onFlush: () => { throw new Error('onFlush failure') } + }) + buf.log('info', 's', T0, {}, ['a']) + buf.log('info', 's', T0, {}, ['b']) + expect(innerReceived).toEqual(['a', 'b']) + }) + + it('inner reporter is called for all entries even when it throws on each', () => { + let callCount = 0 + class R extends LogReporter { + log(): void { callCount++; throw new Error('inner failure') } + } + const buf = new BufferedReporter(new R(), { maxSize: 3, flushInterval: 0 }) + buf.log('info', 's', T0, {}, ['a']) + buf.log('info', 's', T0, {}, ['b']) + buf.log('info', 's', T0, {}, ['c']) + expect(callCount).toBe(3) + }) }) describe('FileLogReporter', () => { @@ -419,8 +1259,8 @@ describe('FileLogReporter', () => { const dir = mkdtempSync(join(tmpdir(), 'tc-log-')) const file = join(dir, 'app.log') const reporter = new FileLogReporter(file) - reporter.log('info', 'svc', '2026-01-01T00:00:00Z', ['hello', { k: 1 }]) - reporter.log('error', 'svc', '2026-01-01T00:00:01Z', [new Error('boom')]) + reporter.log('info', 'svc', T0, {}, ['hello', { k: 1 }]) + reporter.log('error', 'svc', T1, {}, [new Error('boom')]) await reporter.close() const content = readFileSync(file, 'utf8') expect(content).toContain('INFO') @@ -431,6 +1271,17 @@ describe('FileLogReporter', () => { expect(content).toContain('boom') }) + it('invokes onError and does not crash when the path is unwritable', async () => { + const errors: Error[] = [] + const reporter = new FileLogReporter('/nonexistent-dir/does-not-exist/app.log', { + onError: err => errors.push(err) + }) + reporter.log('info', 'svc', T0, {}, ['msg']) + await new Promise(resolve => setTimeout(resolve, 200)) + expect(errors).toHaveLength(1) + expect(errors[0]).toBeInstanceOf(Error) + }) + it('honors custom formatter', async () => { const { mkdtempSync, readFileSync } = await import('node:fs') const { tmpdir } = await import('node:os') @@ -438,11 +1289,2332 @@ describe('FileLogReporter', () => { const dir = mkdtempSync(join(tmpdir(), 'tc-log-')) const file = join(dir, 'app.log') const reporter = new FileLogReporter(file, { - formatter: (level, _scope, _time, messages) => `[${level}] ${messages.join('|')}` + formatter: (level, _scope, _time, _fields, messages) => `[${level}] ${messages.join('|')}` }) - reporter.log('warning', 's', 't', ['a', 'b']) + reporter.log('warning', 's', T0, {}, ['a', 'b']) await reporter.close() const content = readFileSync(file, 'utf8').trim() expect(content).toBe('[warning] a|b') }) + + it('rotates when maxBytes is exceeded', async () => { + const { mkdtempSync, readFileSync, existsSync } = await import('node:fs') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const dir = mkdtempSync(join(tmpdir(), 'tc-log-rot-')) + const file = join(dir, 'app.log') + // Line 1 = 25 bytes, line 2 = 27 bytes; 25+27=52 > maxBytes=50 → rotation on 2nd write + const reporter = new FileLogReporter(file, { + maxBytes: 50, + maxFiles: 3, + formatter: (_level, _scope, _time, _fields, messages) => messages.join(' ') + }) + reporter.log('info', 's', T0, {}, ['line-one-fills-the-file']) + reporter.log('info', 's', T0, {}, ['line-two-goes-to-new-file']) + await reporter.close() + expect(existsSync(file)).toBe(true) + expect(existsSync(`${file}.1`)).toBe(true) + const archived = readFileSync(`${file}.1`, 'utf8') + expect(archived).toContain('line-one-fills-the-file') + const current = readFileSync(file, 'utf8') + expect(current).toContain('line-two-goes-to-new-file') + }) + + it('keeps at most maxFiles archived logs, dropping the oldest', async () => { + const { mkdtempSync, existsSync, writeFileSync, readFileSync } = await import('node:fs') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const dir = mkdtempSync(join(tmpdir(), 'tc-log-maxf-')) + const file = join(dir, 'app.log') + // Pre-seed archives up to the cap (maxFiles = 2) + writeFileSync(`${file}.1`, 'archive-1\n') + writeFileSync(`${file}.2`, 'archive-2\n') + const reporter = new FileLogReporter(file, { + maxBytes: 20, + maxFiles: 2, + formatter: (_l, _s, _t, _f, msgs) => msgs.join('') + }) + // First write fits (6 bytes); second write (21 bytes) pushes over 20 → rotation + reporter.log('info', 's', T0, {}, ['hello']) + reporter.log('info', 's', T0, {}, ['msg-padded-xxxxxxxxx']) + await reporter.close() + // Shift: archive-2 dropped, archive-1 → .2, old app.log (hello) → .1 + expect(existsSync(`${file}.2`)).toBe(true) + expect(existsSync(`${file}.3`)).toBe(false) + expect(readFileSync(`${file}.2`, 'utf8')).toBe('archive-1\n') + expect(readFileSync(`${file}.1`, 'utf8')).toBe('hello\n') + }) +}) + +describe('StreamReporter', () => { + it('writes formatted lines to a Writable', async () => { + const { Writable } = await import('node:stream') + const chunks: Buffer[] = [] + const stream = new Writable({ + write(chunk, _enc, done) { chunks.push(chunk); done() } + }) + const reporter = new StreamReporter(stream) + reporter.log('info', 'svc', T0, {}, ['hello', { k: 1 }]) + await reporter.close() + const content = Buffer.concat(chunks).toString() + expect(content).toContain('INFO') + expect(content).toContain('svc') + expect(content).toContain('hello') + expect(content).toContain('{"k":1}') + }) + + it('uses a custom formatter', async () => { + const { Writable } = await import('node:stream') + const chunks: Buffer[] = [] + const stream = new Writable({ + write(chunk, _enc, done) { chunks.push(chunk); done() } + }) + const reporter = new StreamReporter(stream, { + formatter: (level, _scope, _time, _fields, messages) => `[${level}] ${messages.join('|')}` + }) + reporter.log('warning', 's', T0, {}, ['a', 'b']) + await reporter.close() + const content = Buffer.concat(chunks).toString().trim() + expect(content).toBe('[warning] a|b') + }) + + it('invokes onError when the stream emits an error', async () => { + const { PassThrough } = await import('node:stream') + const errors: Error[] = [] + const stream = new PassThrough() + const reporter = new StreamReporter(stream, { onError: err => errors.push(err) }) + const err = new Error('stream error') + stream.emit('error', err) + expect(errors).toHaveLength(1) + expect(errors[0]).toBe(err) + }) + + it('resets the byte counter when maxBytes is exceeded and continues writing to the same stream', async () => { + const { Writable } = await import('node:stream') + const chunks: Buffer[] = [] + const stream = new Writable({ + write(chunk, _enc, done) { chunks.push(chunk); done() } + }) + const reporter = new StreamReporter(stream, { + maxBytes: 10, + formatter: (_l, _s, _t, _f, msgs) => msgs.join('') + }) + reporter.log('info', 's', T0, {}, ['hello']) // "hello\n" = 6 bytes → written directly + reporter.log('info', 's', T0, {}, ['world']) // "world\n" = 6 bytes → 6+6 > 10 → rotation + await reporter.close() + const content = Buffer.concat(chunks).toString() + expect(content).toContain('hello') + expect(content).toContain('world') + }) + + it('queues multiple lines during rotation and flushes them all afterwards', async () => { + const { Writable } = await import('node:stream') + const chunks: Buffer[] = [] + const stream = new Writable({ + write(chunk, _enc, done) { chunks.push(chunk); done() } + }) + const reporter = new StreamReporter(stream, { + maxBytes: 5, + formatter: (_l, _s, _t, _f, msgs) => msgs.join('') + }) + reporter.log('info', 's', T0, {}, ['abc']) // "abc\n" = 4 bytes → written + reporter.log('info', 's', T0, {}, ['xyz']) // "xyz\n" = 4 bytes → 4+4 > 5 → rotation; queued + reporter.log('info', 's', T0, {}, ['pqr']) // queued during rotation + await reporter.close() + const content = Buffer.concat(chunks).toString() + expect(content).toContain('abc') + expect(content).toContain('xyz') + expect(content).toContain('pqr') + }) + + it('close() ends the stream', async () => { + const { Writable } = await import('node:stream') + let finished = false + const stream = new Writable({ + write(_chunk, _enc, done) { done() } + }) + stream.on('finish', () => { finished = true }) + const reporter = new StreamReporter(stream) + await reporter.close() + expect(finished).toBe(true) + }) + + it('FileLogReporter is a subclass of StreamReporter', () => { + expect(Object.getPrototypeOf(FileLogReporter.prototype)).toBe(StreamReporter.prototype) + }) +}) + +describe('LoggerFactory.setLevel (scope-pattern overrides)', () => { + it('enables a below-global level for a matching scope', () => { + const captured: { level: string, scope: string }[] = [] + class R extends LogReporter { log(level: any, scope: string) { captured.push({ level, scope }) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'warning' + factory.setLevel('db:*', 'debug') + const db = factory.getLogger('db:pool') + db.debug('pool debug') + db.verbose('pool verbose') + expect(captured.map(e => e.level)).toEqual(['debug']) + }) + + it('does not affect scopes that do not match the pattern', () => { + const captured: string[] = [] + class R extends LogReporter { log(level: any) { captured.push(level) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'warning' + factory.setLevel('db:*', 'debug') + factory.getLogger('auth').debug('skipped') + expect(captured).toHaveLength(0) + }) + + it('most-specific pattern wins — db:pool:* over db:*', () => { + const captured: string[] = [] + class R extends LogReporter { log(level: any) { captured.push(level) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'info' + factory.setLevel('db:*', 'debug') + factory.setLevel('db:pool:*', 'warning') + factory.getLogger('db:pool:worker').debug('dropped by db:pool:*') + factory.getLogger('db:pool:worker').warning('shown') + factory.getLogger('db:query').debug('shown via db:*') + expect(captured).toEqual(['warning', 'debug']) + }) + + it('exact pattern beats wildcard', () => { + const captured: string[] = [] + class R extends LogReporter { log(level: any) { captured.push(level) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'info' + factory.setLevel('db:*', 'debug') + factory.setLevel('db:pool', 'error') + factory.getLogger('db:pool').debug('dropped by exact pattern') + factory.getLogger('db:pool').error('shown') + factory.getLogger('db:query').debug('shown via db:*') + expect(captured).toEqual(['error', 'debug']) + }) + + it('per-logger setLevel overrides pattern override', () => { + const captured: string[] = [] + class R extends LogReporter { log(level: any) { captured.push(level) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'info' + factory.setLevel('db:*', 'debug') + const logger = factory.getLogger('db:pool') + logger.setLevel('warning') + logger.debug('dropped by per-logger override') + logger.warning('shown') + expect(captured).toEqual(['warning']) + }) + + it('re-registering the same pattern replaces its level', () => { + const captured: string[] = [] + class R extends LogReporter { log(level: any) { captured.push(level) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'info' + factory.setLevel('db:*', 'debug') + factory.setLevel('db:*', 'error') + factory.getLogger('db:pool').debug('dropped') + factory.getLogger('db:pool').warning('dropped') + factory.getLogger('db:pool').error('shown') + expect(captured).toEqual(['error']) + }) + + it('throws RangeError for an unknown level', () => { + const factory = new LoggerFactory([]) + expect(() => factory.setLevel('db:*', 'trace' as any)).toThrow(RangeError) + }) + + it('isEnabled reflects pattern threshold', () => { + const factory = new LoggerFactory([]) + factory.level = 'warning' + factory.setLevel('db:*', 'debug') + const logger = factory.getLogger('db:pool') + expect(logger.isEnabled('debug')).toBe(true) + expect(logger.isEnabled('verbose')).toBe(false) + expect(factory.getLogger('auth').isEnabled('debug')).toBe(false) + }) + + it('wildcard-only pattern * matches any scope', () => { + const captured: string[] = [] + class R extends LogReporter { log(level: any) { captured.push(level) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'warning' + factory.setLevel('*', 'debug') + factory.getLogger('anything').debug('shown') + factory.getLogger('other:scope').debug('shown') + expect(captured).toEqual(['debug', 'debug']) + }) +}) + +describe('LoggerFactory.parseEnv', () => { + it('LOG_LEVEL sets the global factory level', () => { + const factory = new LoggerFactory([]) + factory.parseEnv({ LOG_LEVEL: 'debug' }) + expect(factory.level).toBe('debug') + }) + + it('DEBUG sets debug level for comma-separated patterns', () => { + const captured: { level: string, scope: string }[] = [] + class R extends LogReporter { log(level: any, scope: string) { captured.push({ level, scope }) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'warning' + factory.parseEnv({ DEBUG: 'auth*,db:*' }) + factory.getLogger('auth').debug('shown') + factory.getLogger('auth:login').debug('shown') + factory.getLogger('db:pool').debug('shown') + factory.getLogger('other').debug('dropped') + expect(captured.map(e => e.level)).toEqual(['debug', 'debug', 'debug']) + expect(captured.map(e => e.scope)).toEqual(['auth', 'auth:login', 'db:pool']) + }) + + it('DEBUG with space-separated patterns', () => { + const captured: string[] = [] + class R extends LogReporter { log(_l: any, scope: string) { captured.push(scope) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'warning' + factory.parseEnv({ DEBUG: 'auth db' }) + factory.getLogger('auth').debug('shown') + factory.getLogger('db').debug('shown') + factory.getLogger('other').debug('dropped') + expect(captured).toEqual(['auth', 'db']) + }) + + it('LOG_LEVEL and DEBUG can be combined', () => { + const factory = new LoggerFactory([]) + factory.parseEnv({ LOG_LEVEL: 'error', DEBUG: 'verbose-svc' }) + expect(factory.level).toBe('error') + expect(factory.getLogger('verbose-svc').isEnabled('debug')).toBe(true) + expect(factory.getLogger('other').isEnabled('info')).toBe(false) + }) + + it('unknown LOG_LEVEL is silently ignored', () => { + const factory = new LoggerFactory([]) + factory.level = 'info' + factory.parseEnv({ LOG_LEVEL: 'trace' }) + expect(factory.level).toBe('info') + }) + + it('missing keys are silently skipped', () => { + const factory = new LoggerFactory([]) + factory.level = 'info' + factory.parseEnv({}) + expect(factory.level).toBe('info') + }) +}) + +describe('Logger.child (hierarchical scopes)', () => { + it('creates a logger with scope parent:child', () => { + const captured: { scope: string }[] = [] + class R extends LogReporter { log(_l: any, scope: string) { captured.push({ scope }) } } + const factory = new LoggerFactory([new R()]) + const db = factory.getLogger('db') + const pool = db.child('pool') + pool.info('msg') + expect(captured[0].scope).toBe('db:pool') + }) + + it('nested child scopes concatenate with colons', () => { + const captured: string[] = [] + class R extends LogReporter { log(_l: any, scope: string) { captured.push(scope) } } + const factory = new LoggerFactory([new R()]) + const pool = factory.getLogger('db').child('pool') + const worker = pool.child('worker') + worker.info('msg') + expect(captured[0]).toBe('db:pool:worker') + }) + + it('child inherits parent level override', () => { + const captured: string[] = [] + class R extends LogReporter { log(level: any) { captured.push(level) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'warning' + const parent = factory.getLogger('svc') + parent.setLevel('verbose') + const child = parent.child('sub') + child.debug('shown via inherited override') + expect(captured).toEqual(['debug']) + }) + + it('child inherits parent withContext fields', () => { + const captured: { fields: any }[] = [] + class R extends LogReporter { log(_l: any, _s: any, _t: any, fields: any) { captured.push({ fields }) } } + const factory = new LoggerFactory([new R()]) + const parent = factory.getLogger('svc').withContext({ requestId: 'r1' }) + const child = parent.child('sub') + child.info('msg') + expect(captured[0].fields).toEqual({ requestId: 'r1' }) + }) + + it('child receives pattern level from factory', () => { + const captured: string[] = [] + class R extends LogReporter { log(level: any) { captured.push(level) } } + const factory = new LoggerFactory([new R()]) + factory.level = 'warning' + factory.setLevel('db:*', 'debug') + const pool = factory.getLogger('db').child('pool') + pool.debug('shown via pattern') + expect(captured).toEqual(['debug']) + }) + + it('child logs pass through the factory reporters', () => { + const captured: string[] = [] + class R extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { captured.push(msgs[0]) } } + const factory = new LoggerFactory([new R()]) + factory.getLogger('db').child('pool').info('hello') + expect(captured).toEqual(['hello']) + }) +}) + +describe('FileLogReporter — ESM smoke (post-build)', () => { + it('constructs without a Dynamic require error when loaded from the built ESM bundle', async () => { + const { existsSync } = await import('node:fs') + // Resolve lib/node.module.js relative to this test file + const builtUrl = new URL('../lib/node.module.js', import.meta.url) + if (!existsSync(builtUrl)) { + // Package has not been built yet; skip silently rather than fail CI + return + } + // Dynamic import of the ESM bundle — must not throw "Dynamic require of ... is not supported" + const mod = await import(/* @vite-ignore */ builtUrl.href) + const FileLogReporterBuilt: typeof FileLogReporter = mod.default + const reporter = new FileLogReporterBuilt('/tmp/esm-smoke-test.log') + await reporter.close() + }) +}) + +describe('RingBufferReporter', () => { + it('throws when capacity is not a positive integer', () => { + expect(() => new RingBufferReporter(0)).toThrow() + expect(() => new RingBufferReporter(-1)).toThrow() + expect(() => new RingBufferReporter(1.5)).toThrow() + }) + + it('buffers entries up to capacity', () => { + const reporter = new RingBufferReporter(3) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + factory.getLogger('a').info('one') + factory.getLogger('a').info('two') + factory.getLogger('a').info('three') + expect(reporter.size).toBe(3) + expect(reporter.snapshot()).toHaveLength(3) + }) + + it('overwrites the oldest entry when full', () => { + const reporter = new RingBufferReporter(3) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + const log = factory.getLogger('a') + log.info('one') + log.info('two') + log.info('three') + log.info('four') + const snap = reporter.snapshot() + expect(snap).toHaveLength(3) + expect(snap[0].messages[0]).toBe('two') + expect(snap[1].messages[0]).toBe('three') + expect(snap[2].messages[0]).toBe('four') + }) + + it('snapshot returns entries in insertion order (oldest to newest)', () => { + const reporter = new RingBufferReporter(5) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + const log = factory.getLogger('s') + log.info('a') + log.info('b') + log.info('c') + const snap = reporter.snapshot() + expect(snap.map(e => e.messages[0])).toEqual(['a', 'b', 'c']) + }) + + it('snapshot returns a shallow copy — mutating it does not affect the buffer', () => { + const reporter = new RingBufferReporter(3) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + factory.getLogger('s').info('msg') + const snap = reporter.snapshot() + snap.pop() + expect(reporter.size).toBe(1) + }) + + it('size reflects the number of buffered entries', () => { + const reporter = new RingBufferReporter(5) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + expect(reporter.size).toBe(0) + factory.getLogger('s').info('x') + expect(reporter.size).toBe(1) + factory.getLogger('s').info('y') + expect(reporter.size).toBe(2) + }) + + it('size is capped at capacity after overflow', () => { + const reporter = new RingBufferReporter(2) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + factory.getLogger('s').info('a') + factory.getLogger('s').info('b') + factory.getLogger('s').info('c') + expect(reporter.size).toBe(2) + }) + + it('capacity getter returns the configured limit', () => { + const reporter = new RingBufferReporter(7) + expect(reporter.capacity).toBe(7) + }) + + it('clear resets size to zero and snapshot returns empty array', () => { + const reporter = new RingBufferReporter(4) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + factory.getLogger('s').info('a') + factory.getLogger('s').info('b') + reporter.clear() + expect(reporter.size).toBe(0) + expect(reporter.snapshot()).toHaveLength(0) + }) + + it('clear preserves capacity — new entries can be pushed after clear', () => { + const reporter = new RingBufferReporter(3) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + factory.getLogger('s').info('a') + reporter.clear() + factory.getLogger('s').info('b') + const snap = reporter.snapshot() + expect(snap).toHaveLength(1) + expect(snap[0].messages[0]).toBe('b') + }) + + it('respects factory level filtering', () => { + const reporter = new RingBufferReporter(10) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'warning' + const log = factory.getLogger('s') + log.debug('dropped') + log.info('dropped') + log.warning('kept') + log.error('kept') + expect(reporter.size).toBe(2) + }) + + it('captures level, scope, time, fields, and messages on each entry', () => { + const reporter = new RingBufferReporter(5) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + const log = factory.getLogger('svc').withContext({ reqId: 'r1' }) + log.warning('something happened', { detail: 42 }) + const [entry] = reporter.snapshot() + expect(entry.level).toBe('warning') + expect(entry.scope).toBe('svc') + expect(entry.time).toBe(T0) + expect(entry.fields).toEqual({ reqId: 'r1' }) + expect(entry.messages).toEqual(['something happened', { detail: 42 }]) + }) + + it('ring wraps correctly across multiple overwrite cycles', () => { + const reporter = new RingBufferReporter(3) + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + const log = factory.getLogger('s') + for (let i = 1; i <= 9; i++) { + log.info(`msg-${i}`) + } + const snap = reporter.snapshot() + expect(snap.map(e => e.messages[0])).toEqual(['msg-7', 'msg-8', 'msg-9']) + }) +}) + +describe('LevelFilterReporter', () => { + it('forwards entries at or above the min level', () => { + const captured: string[] = [] + class Sink extends LogReporter { + log(level: any) { captured.push(level) } + } + const reporter = new LevelFilterReporter(new Sink(), 'warning') + reporter.log('error', 's', T0, {}, ['e']) + reporter.log('warning', 's', T0, {}, ['w']) + reporter.log('info', 's', T0, {}, ['i']) + reporter.log('debug', 's', T0, {}, ['d']) + expect(captured).toEqual(['error', 'warning']) + }) + + it('forwards all entries when minLevel is verbose', () => { + const captured: string[] = [] + class Sink extends LogReporter { + log(level: any) { captured.push(level) } + } + const reporter = new LevelFilterReporter(new Sink(), 'verbose') + reporter.log('error', 's', T0, {}, ['e']) + reporter.log('verbose', 's', T0, {}, ['v']) + expect(captured).toEqual(['error', 'verbose']) + }) + + it('blocks entries below minLevel', () => { + const captured: string[] = [] + class Sink extends LogReporter { + log(level: any) { captured.push(level) } + } + const reporter = new LevelFilterReporter(new Sink(), 'error') + reporter.log('warning', 's', T0, {}, ['w']) + reporter.log('info', 's', T0, {}, ['i']) + reporter.log('debug', 's', T0, {}, ['d']) + expect(captured).toHaveLength(0) + }) + + it('passes all fields and messages unmodified to inner reporter', () => { + const captured: any[] = [] + class Sink extends LogReporter { + log(_l: any, scope: string, time: number, fields: any, msgs: any[]) { + captured.push({ scope, time, fields, msgs }) + } + } + const reporter = new LevelFilterReporter(new Sink(), 'info') + reporter.log('error', 'svc', T0, { reqId: 'r1' }, ['boom']) + expect(captured[0]).toEqual({ scope: 'svc', time: T0, fields: { reqId: 'r1' }, msgs: ['boom'] }) + }) + + it('delegates flush() to inner reporter', () => { + let flushed = false + class Sink extends LogReporter { + log() {} + flush() { flushed = true } + } + new LevelFilterReporter(new Sink(), 'info').flush() + expect(flushed).toBe(true) + }) + + it('delegates close() to inner reporter', async () => { + let closed = false + class Sink extends LogReporter { + log() {} + close() { closed = true } + } + await new LevelFilterReporter(new Sink(), 'info').close() + expect(closed).toBe(true) + }) + + it('can be combined with a factory to add per-reporter threshold', () => { + const captured: string[] = [] + class Sink extends LogReporter { + log(level: any) { captured.push(level) } + } + const factory = new LoggerFactory([new LevelFilterReporter(new Sink(), 'error')]) + factory.level = 'verbose' + factory.getLogger('t').error('e') + factory.getLogger('t').warning('w') + expect(captured).toEqual(['error']) + }) +}) + +describe('ScopeFilterReporter', () => { + it('forwards entries whose scope matches the glob pattern', () => { + const captured: string[] = [] + class Sink extends LogReporter { + log(_l: any, scope: string) { captured.push(scope) } + } + const reporter = new ScopeFilterReporter(new Sink(), 'db:*') + reporter.log('info', 'db:pool', T0, {}, ['x']) + reporter.log('info', 'db:query', T0, {}, ['x']) + reporter.log('info', 'auth', T0, {}, ['x']) + expect(captured).toEqual(['db:pool', 'db:query']) + }) + + it('exact pattern matches only that scope', () => { + const captured: string[] = [] + class Sink extends LogReporter { + log(_l: any, scope: string) { captured.push(scope) } + } + const reporter = new ScopeFilterReporter(new Sink(), 'auth') + reporter.log('info', 'auth', T0, {}, ['x']) + reporter.log('info', 'auth:login', T0, {}, ['x']) + expect(captured).toEqual(['auth']) + }) + + it('wildcard-only pattern matches any scope', () => { + const captured: string[] = [] + class Sink extends LogReporter { + log(_l: any, scope: string) { captured.push(scope) } + } + const reporter = new ScopeFilterReporter(new Sink(), '*') + reporter.log('info', 'auth', T0, {}, ['x']) + reporter.log('info', 'db:pool', T0, {}, ['x']) + expect(captured).toEqual(['auth', 'db:pool']) + }) + + it('prefix wildcard matches scopes with a shared prefix', () => { + const captured: string[] = [] + class Sink extends LogReporter { + log(_l: any, scope: string) { captured.push(scope) } + } + const reporter = new ScopeFilterReporter(new Sink(), 'http*') + reporter.log('info', 'http', T0, {}, ['x']) + reporter.log('info', 'http:request', T0, {}, ['x']) + reporter.log('info', 'authentication', T0, {}, ['x']) + expect(captured).toEqual(['http', 'http:request']) + }) + + it('delegates flush() to inner reporter', () => { + let flushed = false + class Sink extends LogReporter { + log() {} + flush() { flushed = true } + } + new ScopeFilterReporter(new Sink(), 'db:*').flush() + expect(flushed).toBe(true) + }) + + it('delegates close() to inner reporter', async () => { + let closed = false + class Sink extends LogReporter { + log() {} + close() { closed = true } + } + await new ScopeFilterReporter(new Sink(), 'db:*').close() + expect(closed).toBe(true) + }) +}) + +describe('RedactionReporter', () => { + it('redacts keys matching a string list in messages', () => { + const captured: any[] = [] + class Sink extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { captured.push(...msgs) } + } + const reporter = new RedactionReporter(new Sink(), ['password', 'authorization']) + reporter.log('info', 's', T0, {}, [{ user: 'alice', password: 'secret', token: 'x' }]) + expect(captured[0].password).toBe('[REDACTED]') + expect(captured[0].user).toBe('alice') + expect(captured[0].token).toBe('x') + }) + + it('redacts keys matching a regex in messages', () => { + const captured: any[] = [] + class Sink extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { captured.push(...msgs) } + } + const reporter = new RedactionReporter(new Sink(), /password|secret/i) + reporter.log('info', 's', T0, {}, [{ Password: 'x', secretKey: 'y', other: 'z' }]) + expect(captured[0].Password).toBe('[REDACTED]') + expect(captured[0].secretKey).toBe('[REDACTED]') + expect(captured[0].other).toBe('z') + }) + + it('redacts matching keys in fields', () => { + const captured: any[] = [] + class Sink extends LogReporter { + log(_l: any, _s: any, _t: any, fields: any) { captured.push(fields) } + } + const reporter = new RedactionReporter(new Sink(), ['authorization']) + reporter.log('info', 's', T0, { authorization: 'Bearer tok', reqId: 'r1' }, []) + expect(captured[0].authorization).toBe('[REDACTED]') + expect(captured[0].reqId).toBe('r1') + }) + + it('redacts nested keys inside objects', () => { + const captured: any[] = [] + class Sink extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { captured.push(...msgs) } + } + const reporter = new RedactionReporter(new Sink(), ['password']) + reporter.log('info', 's', T0, {}, [{ user: { name: 'alice', password: 'secret' } }]) + expect(captured[0].user.name).toBe('alice') + expect(captured[0].user.password).toBe('[REDACTED]') + }) + + it('redacts matching keys inside arrays in messages', () => { + const captured: any[] = [] + class Sink extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { captured.push(...msgs) } + } + const reporter = new RedactionReporter(new Sink(), ['password']) + reporter.log('info', 's', T0, {}, [[{ password: 's' }, { password: 't' }]]) + expect(captured[0][0].password).toBe('[REDACTED]') + expect(captured[0][1].password).toBe('[REDACTED]') + }) + + it('passes Error instances through without modification', () => { + const captured: any[] = [] + class Sink extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { captured.push(...msgs) } + } + const err = new Error('boom') + const reporter = new RedactionReporter(new Sink(), ['password']) + reporter.log('error', 's', T0, {}, [err]) + expect(captured[0]).toBe(err) + }) + + it('handles circular references without throwing', () => { + const captured: any[] = [] + class Sink extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { captured.push(...msgs) } + } + const obj: any = { name: 'node', password: 'secret' } + obj.self = obj + const reporter = new RedactionReporter(new Sink(), ['password']) + expect(() => reporter.log('info', 's', T0, {}, [obj])).not.toThrow() + expect(captured[0].password).toBe('[REDACTED]') + expect(captured[0].name).toBe('node') + }) + + it('does not mutate the original messages', () => { + const original = { user: 'alice', password: 'secret' } + class Sink extends LogReporter { log() {} } + const reporter = new RedactionReporter(new Sink(), ['password']) + reporter.log('info', 's', T0, {}, [original]) + expect(original.password).toBe('secret') + }) + + it('delegates flush() to inner reporter', () => { + let flushed = false + class Sink extends LogReporter { + log() {} + flush() { flushed = true } + } + new RedactionReporter(new Sink(), ['password']).flush() + expect(flushed).toBe(true) + }) + + it('delegates close() to inner reporter', async () => { + let closed = false + class Sink extends LogReporter { + log() {} + close() { closed = true } + } + await new RedactionReporter(new Sink(), ['password']).close() + expect(closed).toBe(true) + }) +}) + +describe('SamplingReporter', () => { + it('forwards all entries when rate is 1', () => { + const captured: number[] = [] + class Sink extends LogReporter { + log() { captured.push(1) } + } + const reporter = new SamplingReporter(new Sink(), 1) + reporter.log('info', 's', T0, {}, ['a']) + reporter.log('info', 's', T0, {}, ['b']) + reporter.log('info', 's', T0, {}, ['c']) + expect(captured).toHaveLength(3) + }) + + it('drops all entries when rate is 0', () => { + const captured: number[] = [] + class Sink extends LogReporter { + log() { captured.push(1) } + } + const reporter = new SamplingReporter(new Sink(), 0) + reporter.log('info', 's', T0, {}, ['a']) + reporter.log('info', 's', T0, {}, ['b']) + expect(captured).toHaveLength(0) + }) + + it('forwards an entry when Math.random() is below the rate', () => { + const spy = vi.spyOn(Math, 'random').mockReturnValue(0.3) + const captured: number[] = [] + class Sink extends LogReporter { + log() { captured.push(1) } + } + new SamplingReporter(new Sink(), 0.5).log('info', 's', T0, {}, ['a']) + expect(captured).toHaveLength(1) + spy.mockRestore() + }) + + it('drops an entry when Math.random() is at or above the rate', () => { + const spy = vi.spyOn(Math, 'random').mockReturnValue(0.9) + const captured: number[] = [] + class Sink extends LogReporter { + log() { captured.push(1) } + } + new SamplingReporter(new Sink(), 0.5).log('info', 's', T0, {}, ['a']) + expect(captured).toHaveLength(0) + spy.mockRestore() + }) + + it('throws RangeError for rate below 0', () => { + class Sink extends LogReporter { log() {} } + expect(() => new SamplingReporter(new Sink(), -0.1)).toThrow(RangeError) + }) + + it('throws RangeError for rate above 1', () => { + class Sink extends LogReporter { log() {} } + expect(() => new SamplingReporter(new Sink(), 1.1)).toThrow(RangeError) + }) + + it('delegates flush() to inner reporter', () => { + let flushed = false + class Sink extends LogReporter { + log() {} + flush() { flushed = true } + } + new SamplingReporter(new Sink(), 1).flush() + expect(flushed).toBe(true) + }) + + it('delegates close() to inner reporter', async () => { + let closed = false + class Sink extends LogReporter { + log() {} + close() { closed = true } + } + await new SamplingReporter(new Sink(), 1).close() + expect(closed).toBe(true) + }) +}) + +describe('FanoutReporter', () => { + it('forwards log entries to all inner reporters', () => { + const a: string[] = [] + const b: string[] = [] + class A extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { a.push(msgs[0]) } } + class B extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { b.push(msgs[0]) } } + const reporter = new FanoutReporter([new A(), new B()]) + reporter.log('info', 's', T0, {}, ['hello']) + expect(a).toEqual(['hello']) + expect(b).toEqual(['hello']) + }) + + it('continues calling remaining reporters after one throws', () => { + const captured: string[] = [] + class Throws extends LogReporter { log() { throw new Error('boom') } } + class Good extends LogReporter { log(_l: any, _s: any, _t: any, _f: any, msgs: any[]) { captured.push(msgs[0]) } } + const reporter = new FanoutReporter([new Throws(), new Good()]) + expect(() => reporter.log('info', 's', T0, {}, ['msg'])).not.toThrow() + expect(captured).toEqual(['msg']) + }) + + it('flush() calls flush() on all inner reporters', () => { + const flushed: string[] = [] + class F extends LogReporter { + constructor(private id: string) { super() } + log() {} + flush() { flushed.push(this.id) } + } + new FanoutReporter([new F('a'), new F('b')]).flush() + expect(flushed).toEqual(['a', 'b']) + }) + + it('flush() isolates reporter errors', () => { + class Throws extends LogReporter { + log() {} + flush() { throw new Error('flush boom') } + } + expect(() => new FanoutReporter([new Throws()]).flush()).not.toThrow() + }) + + it('close() calls close() on all inner reporters', async () => { + const closed: string[] = [] + class C extends LogReporter { + constructor(private id: string) { super() } + log() {} + close() { closed.push(this.id) } + } + await new FanoutReporter([new C('a'), new C('b')]).close() + expect(closed).toEqual(['a', 'b']) + }) + + it('close() awaits async close() on reporters', async () => { + let resolved = false + class AsyncC extends LogReporter { + log() {} + close(): Promise { + return new Promise((r) => setTimeout(() => { resolved = true; r() }, 10)) + } + } + await new FanoutReporter([new AsyncC()]).close() + expect(resolved).toBe(true) + }) + + it('close() isolates reporter errors', async () => { + class Throws extends LogReporter { + log() {} + close() { throw new Error('close boom') } + } + await expect(new FanoutReporter([new Throws()]).close()).resolves.toBeUndefined() + }) + + it('MultiReporter is an alias for FanoutReporter', () => { + expect(MultiReporter).toBe(FanoutReporter) + }) + + it('can compose with LevelFilterReporter for per-sink thresholds', () => { + const errors: string[] = [] + const all: string[] = [] + class ErrorSink extends LogReporter { log(level: any) { errors.push(level) } } + class AllSink extends LogReporter { log(level: any) { all.push(level) } } + const fanout = new FanoutReporter([ + new LevelFilterReporter(new ErrorSink(), 'error'), + new AllSink(), + ]) + const factory = new LoggerFactory([fanout]) + factory.level = 'verbose' + factory.getLogger('t').error('e') + factory.getLogger('t').info('i') + expect(errors).toEqual(['error']) + expect(all).toEqual(['error', 'info']) + }) +}) + +describe('HTTPReporter', () => { + it('batches entries and posts them as a JSON body on flush', async () => { + const posts: { url: string; body: any }[] = [] + const transport: HTTPTransport = async (url, body) => { + posts.push({ url, body: JSON.parse(body) }) + return 200 + } + const reporter = new HTTPReporter({ + url: 'https://logs.example.com/ingest', + maxSize: 3, + flushInterval: 0, + transport, + }) + reporter.log('info', 'svc', T0, {}, ['a']) + reporter.log('warning', 'svc', T0, {}, ['b']) + reporter.log('error', 'svc', T0, {}, ['c']) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(posts).toHaveLength(1) + expect(posts[0].url).toBe('https://logs.example.com/ingest') + expect(posts[0].body.entries).toHaveLength(3) + }) + + it('payload entries carry level, scope, time, fields, and messages', async () => { + const posts: any[] = [] + const transport: HTTPTransport = async (_url, body) => { + posts.push(JSON.parse(body)) + return 200 + } + const reporter = new HTTPReporter({ + url: 'https://logs.example.com/ingest', + maxSize: 1, + flushInterval: 0, + transport, + }) + reporter.log('warning', 'auth', T0, { requestId: 'r1' }, ['login failed', { ip: '1.2.3.4' }]) + await new Promise(resolve => setTimeout(resolve, 0)) + const entry = posts[0].entries[0] + expect(entry.level).toBe('warning') + expect(entry.scope).toBe('auth') + expect(entry.time).toBe(T0) + expect(entry.fields).toEqual({ requestId: 'r1' }) + expect(entry.messages).toEqual(['login failed', { ip: '1.2.3.4' }]) + }) + + it('sends configured headers to the transport', async () => { + const received: Record[] = [] + const transport: HTTPTransport = async (_url, _body, headers) => { + received.push(headers) + return 200 + } + const reporter = new HTTPReporter({ + url: 'https://logs.example.com/ingest', + maxSize: 1, + flushInterval: 0, + headers: { Authorization: 'Bearer token123', 'X-Service': 'api' }, + transport, + }) + reporter.log('info', 't', T0, {}, ['x']) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(received[0]['Authorization']).toBe('Bearer token123') + expect(received[0]['X-Service']).toBe('api') + }) + + it('retries when the transport throws', async () => { + let callCount = 0 + const transport: HTTPTransport = async () => { + callCount++ + if (callCount < 2) throw new Error('network error') + return 200 + } + const reporter = new HTTPReporter({ + url: 'https://logs.example.com/ingest', + maxSize: 1, + flushInterval: 0, + retries: 2, + retryMinTimeout: 0, + transport, + }) + reporter.log('info', 't', T0, {}, ['msg']) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(callCount).toBe(2) + }) + + it('retries when the transport returns a non-2xx status', async () => { + let callCount = 0 + const transport: HTTPTransport = async () => { + callCount++ + return callCount < 2 ? 503 : 200 + } + const reporter = new HTTPReporter({ + url: 'https://logs.example.com/ingest', + maxSize: 1, + flushInterval: 0, + retries: 2, + retryMinTimeout: 0, + transport, + }) + reporter.log('info', 't', T0, {}, ['msg']) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(callCount).toBe(2) + }) + + it('does not throw when transport permanently fails', async () => { + const transport: HTTPTransport = async () => { throw new Error('permanent failure') } + const reporter = new HTTPReporter({ + url: 'https://logs.example.com/ingest', + maxSize: 1, + flushInterval: 0, + retries: 1, + retryMinTimeout: 0, + transport, + }) + expect(() => reporter.log('info', 't', T0, {}, ['msg'])).not.toThrow() + await new Promise(resolve => setTimeout(resolve, 50)) + }) + + it('respects factory level filtering', async () => { + const posts: any[] = [] + const transport: HTTPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new HTTPReporter({ + url: 'https://logs.example.com/ingest', + maxSize: 1, + flushInterval: 0, + transport, + }) + const factory = new LoggerFactory([reporter]) + factory.level = 'warning' + factory.getLogger('t').debug('dropped') + factory.getLogger('t').info('dropped') + factory.getLogger('t').warning('kept') + await new Promise(resolve => setTimeout(resolve, 0)) + expect(posts).toHaveLength(1) + expect(posts[0].entries[0].level).toBe('warning') + }) + + it('flush() drains buffer immediately and posts the batch', async () => { + const posts: any[] = [] + const transport: HTTPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new HTTPReporter({ + url: 'https://logs.example.com/ingest', + maxSize: 100, + flushInterval: 99999, + transport, + }) + reporter.log('info', 't', T0, {}, ['a']) + reporter.log('info', 't', T0, {}, ['b']) + reporter.flush() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(posts).toHaveLength(1) + expect(posts[0].entries).toHaveLength(2) + }) +}) + +describe('OTLPReporter — level→severity mapping', () => { + const mkReporter = () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + transport, + }) + return { reporter, posts } + } + + it('verbose maps to severityNumber 1 and severityText TRACE', async () => { + const { reporter, posts } = mkReporter() + reporter.log('verbose', 'svc', T0, {}, ['trace msg']) + await new Promise(resolve => setTimeout(resolve, 0)) + const rec = posts[0].resourceLogs[0].scopeLogs[0].logRecords[0] + expect(rec.severityNumber).toBe(1) + expect(rec.severityText).toBe('TRACE') + }) + + it('debug maps to severityNumber 5 and severityText DEBUG', async () => { + const { reporter, posts } = mkReporter() + reporter.log('debug', 'svc', T0, {}, ['debug msg']) + await new Promise(resolve => setTimeout(resolve, 0)) + const rec = posts[0].resourceLogs[0].scopeLogs[0].logRecords[0] + expect(rec.severityNumber).toBe(5) + expect(rec.severityText).toBe('DEBUG') + }) + + it('info maps to severityNumber 9 and severityText INFO', async () => { + const { reporter, posts } = mkReporter() + reporter.log('info', 'svc', T0, {}, ['info msg']) + await new Promise(resolve => setTimeout(resolve, 0)) + const rec = posts[0].resourceLogs[0].scopeLogs[0].logRecords[0] + expect(rec.severityNumber).toBe(9) + expect(rec.severityText).toBe('INFO') + }) + + it('warning maps to severityNumber 13 and severityText WARN', async () => { + const { reporter, posts } = mkReporter() + reporter.log('warning', 'svc', T0, {}, ['warn msg']) + await new Promise(resolve => setTimeout(resolve, 0)) + const rec = posts[0].resourceLogs[0].scopeLogs[0].logRecords[0] + expect(rec.severityNumber).toBe(13) + expect(rec.severityText).toBe('WARN') + }) + + it('error maps to severityNumber 17 and severityText ERROR', async () => { + const { reporter, posts } = mkReporter() + reporter.log('error', 'svc', T0, {}, ['err msg']) + await new Promise(resolve => setTimeout(resolve, 0)) + const rec = posts[0].resourceLogs[0].scopeLogs[0].logRecords[0] + expect(rec.severityNumber).toBe(17) + expect(rec.severityText).toBe('ERROR') + }) +}) + +describe('OTLPReporter — fields become attributes', () => { + it('context fields from withContext() appear as OTLP attributes', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + transport, + }) + reporter.log('info', 'auth', T0, { requestId: 'r1', userId: 42 }, ['login']) + await new Promise(resolve => setTimeout(resolve, 0)) + const attrs: Array<{ key: string; value: any }> = + posts[0].resourceLogs[0].scopeLogs[0].logRecords[0].attributes + const byKey = Object.fromEntries(attrs.map(a => [a.key, a.value])) + expect(byKey['requestId']).toEqual({ stringValue: 'r1' }) + expect(byKey['userId']).toEqual({ intValue: '42' }) + }) + + it('scope appears as the log.scope attribute', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + transport, + }) + reporter.log('info', 'payments', T0, {}, ['charged']) + await new Promise(resolve => setTimeout(resolve, 0)) + const attrs: Array<{ key: string; value: any }> = + posts[0].resourceLogs[0].scopeLogs[0].logRecords[0].attributes + const byKey = Object.fromEntries(attrs.map(a => [a.key, a.value])) + expect(byKey['log.scope']).toEqual({ stringValue: 'payments' }) + }) + + it('scope appears as the instrumentation scope name', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + transport, + }) + reporter.log('info', 'db:pool', T0, {}, ['connected']) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(posts[0].resourceLogs[0].scopeLogs[0].scope.name).toBe('db:pool') + }) + + it('resource attributes appear in the resource envelope', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + resource: { 'service.name': 'api', 'deployment.environment': 'prod' }, + transport, + }) + reporter.log('info', 'svc', T0, {}, ['boot']) + await new Promise(resolve => setTimeout(resolve, 0)) + const attrs: Array<{ key: string; value: any }> = + posts[0].resourceLogs[0].resource.attributes + const byKey = Object.fromEntries(attrs.map(a => [a.key, a.value])) + expect(byKey['service.name']).toEqual({ stringValue: 'api' }) + expect(byKey['deployment.environment']).toEqual({ stringValue: 'prod' }) + }) + + it('timeUnixNano is epoch ms converted to nanoseconds as a string', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + transport, + }) + reporter.log('info', 'svc', T0, {}, ['msg']) + await new Promise(resolve => setTimeout(resolve, 0)) + const rec = posts[0].resourceLogs[0].scopeLogs[0].logRecords[0] + expect(rec.timeUnixNano).toBe(String(T0 * 1_000_000)) + }) + + it('messages are joined into a string body', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + transport, + }) + reporter.log('info', 'svc', T0, {}, ['server started', { port: 3000 }]) + await new Promise(resolve => setTimeout(resolve, 0)) + const rec = posts[0].resourceLogs[0].scopeLogs[0].logRecords[0] + expect(rec.body.stringValue).toBe('server started {"port":3000}') + }) +}) + +describe('OTLPReporter — batching and transport', () => { + it('groups multiple entries by scope into separate scopeLogs sections', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 4, + flushInterval: 0, + transport, + }) + reporter.log('info', 'auth', T0, {}, ['a']) + reporter.log('info', 'db', T0, {}, ['b']) + reporter.log('info', 'auth', T0, {}, ['c']) + reporter.log('info', 'db', T0, {}, ['d']) + await new Promise(resolve => setTimeout(resolve, 0)) + const scopeLogs = posts[0].resourceLogs[0].scopeLogs + const authScope = scopeLogs.find((s: any) => s.scope.name === 'auth') + const dbScope = scopeLogs.find((s: any) => s.scope.name === 'db') + expect(authScope.logRecords).toHaveLength(2) + expect(dbScope.logRecords).toHaveLength(2) + }) + + it('sends configured headers to the transport', async () => { + const received: Record[] = [] + const transport: OTLPTransport = async (_url, _body, headers) => { + received.push(headers) + return 200 + } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + headers: { 'otlp-api-key': 'secret', 'X-Service': 'api' }, + transport, + }) + reporter.log('info', 't', T0, {}, ['x']) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(received[0]['otlp-api-key']).toBe('secret') + expect(received[0]['X-Service']).toBe('api') + }) + + it('retries when the transport returns a non-2xx status', async () => { + let callCount = 0 + const transport: OTLPTransport = async () => { + callCount++ + return callCount < 2 ? 503 : 200 + } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + retries: 2, + retryMinTimeout: 0, + transport, + }) + reporter.log('info', 't', T0, {}, ['msg']) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(callCount).toBe(2) + }) + + it('does not throw when transport permanently fails', async () => { + const transport: OTLPTransport = async () => { throw new Error('permanent failure') } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + retries: 1, + retryMinTimeout: 0, + transport, + }) + expect(() => reporter.log('info', 't', T0, {}, ['msg'])).not.toThrow() + await new Promise(resolve => setTimeout(resolve, 50)) + }) + + it('respects factory level filtering', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + transport, + }) + const factory = new LoggerFactory([reporter]) + factory.level = 'warning' + factory.getLogger('t').debug('dropped') + factory.getLogger('t').info('dropped') + factory.getLogger('t').warning('kept') + await new Promise(resolve => setTimeout(resolve, 0)) + expect(posts).toHaveLength(1) + const rec = posts[0].resourceLogs[0].scopeLogs[0].logRecords[0] + expect(rec.severityNumber).toBe(13) + expect(rec.severityText).toBe('WARN') + }) + + it('flush() drains buffer immediately', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 100, + flushInterval: 99999, + transport, + }) + reporter.log('info', 't', T0, {}, ['a']) + reporter.log('info', 't', T0, {}, ['b']) + reporter.flush() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(posts).toHaveLength(1) + expect(posts[0].resourceLogs[0].scopeLogs[0].logRecords).toHaveLength(2) + }) + + it('fields from withContext() flow through factory into OTLP attributes', async () => { + const posts: any[] = [] + const transport: OTLPTransport = async (_url, body) => { posts.push(JSON.parse(body)); return 200 } + const reporter = new OTLPReporter({ + url: 'https://otel.example.com/v1/logs', + maxSize: 1, + flushInterval: 0, + transport, + }) + const factory = new LoggerFactory([reporter]) + factory.level = 'debug' + const log = factory.getLogger('api').withContext({ requestId: 'r-99' }) + log.info('handled') + await new Promise(resolve => setTimeout(resolve, 0)) + const attrs: Array<{ key: string; value: any }> = + posts[0].resourceLogs[0].scopeLogs[0].logRecords[0].attributes + const byKey = Object.fromEntries(attrs.map((a: any) => [a.key, a.value])) + expect(byKey['requestId']).toEqual({ stringValue: 'r-99' }) + }) +}) + +// --------------------------------------------------------------------------- +// Minimal in-memory IDB shim +// --------------------------------------------------------------------------- + +function makeFakeIDB() { + const stores: Record; nextKey: number }> = {} + + function makeRequest(result: T) { + const req: any = { result, error: null, onsuccess: null, onerror: null } + Promise.resolve().then(() => req.onsuccess?.({ target: req })) + return req + } + + function makeObjectStore(name: string) { + const s = stores[name] + return { + add(value: any) { + const key = s.nextKey++ + s.data.set(key, value) + return makeRequest(key) + }, + count() { return makeRequest(s.data.size) }, + openCursor() { + const keys = [...s.data.keys()].sort((a, b) => a - b) + let idx = 0 + const req: any = { result: null, error: null, onsuccess: null, onerror: null } + const advance = () => { + if (idx < keys.length) { + const k = keys[idx] + req.result = { + primaryKey: k, + value: s.data.get(k), + delete() { s.data.delete(k) }, + continue() { + idx++ + Promise.resolve().then(() => { advance(); req.onsuccess?.({ target: req }) }) + } + } + } else { + req.result = null + } + } + Promise.resolve().then(() => { advance(); req.onsuccess?.({ target: req }) }) + return req + }, + getAll() { return makeRequest([...s.data.values()]) }, + clear() { s.data.clear(); return makeRequest(undefined) }, + } + } + + function makeDB() { + return { + objectStoreNames: { contains: (name: string) => name in stores }, + createObjectStore(name: string, _opts?: any) { + stores[name] = { data: new Map(), nextKey: 1 } + return makeObjectStore(name) + }, + transaction(storeName: string, _mode?: string) { + const tx: any = { oncomplete: null, onerror: null, objectStore: () => makeObjectStore(storeName) } + setTimeout(() => tx.oncomplete?.(), 0) + return tx + }, + close() {}, + } + } + + const db = makeDB() + return { + open(_name: string, _version: number) { + const req: any = { result: null, error: null, onupgradeneeded: null, onsuccess: null, onerror: null } + Promise.resolve().then(() => { + req.result = db + req.onupgradeneeded?.({ target: req }) + Promise.resolve().then(() => req.onsuccess?.({ target: req })) + }) + return req + }, + deleteDatabase(_name: string) { + const req: any = { onsuccess: null } + Promise.resolve().then(() => req.onsuccess?.()) + return req + }, + } +} + +// --------------------------------------------------------------------------- +// BeaconReporter tests +// --------------------------------------------------------------------------- + +describe('BeaconReporter', () => { + + it('throws when navigator.sendBeacon is absent', () => { + const orig = (globalThis as any).navigator + ;(globalThis as any).navigator = undefined + try { + expect(() => new BeaconReporter({ url: '/logs' })).toThrow('navigator.sendBeacon') + } finally { + ;(globalThis as any).navigator = orig + } + }) + + it('buffers entries and sends them via sendBeacon on flush', () => { + const orig = (globalThis as any).navigator + const calls: { url: string; data: string }[] = [] + ;(globalThis as any).navigator = { sendBeacon: (url: string, data: string) => { calls.push({ url, data }); return true } } + try { + const reporter = new BeaconReporter({ url: '/logs', maxSize: 100, flushInterval: 0 }) + reporter.log('info', 'svc', T0, {}, ['hello']) + reporter.log('warning', 'svc', T0, {}, ['world']) + reporter.flush() + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe('/logs') + const entries = JSON.parse(calls[0].data) + expect(entries).toHaveLength(2) + expect(entries[0].level).toBe('info') + expect(entries[1].level).toBe('warning') + } finally { + ;(globalThis as any).navigator = orig + } + }) + + it('each entry carries level, scope, time, fields, and messages', () => { + const orig = (globalThis as any).navigator + const calls: any[] = [] + ;(globalThis as any).navigator = { sendBeacon: (_url: string, data: string) => { calls.push(JSON.parse(data)); return true } } + try { + const reporter = new BeaconReporter({ url: '/logs', maxSize: 1, flushInterval: 0 }) + reporter.log('error', 'auth', T0, { reqId: 'r1' }, ['boom', { code: 42 }]) + const entry = calls[0][0] + expect(entry.level).toBe('error') + expect(entry.scope).toBe('auth') + expect(entry.time).toBe(T0) + expect(entry.fields).toEqual({ reqId: 'r1' }) + expect(entry.messages).toEqual(['boom', { code: 42 }]) + } finally { + ;(globalThis as any).navigator = orig + } + }) + + it('flushes buffered entries via pagehide', () => { + const origNav = (globalThis as any).navigator + const origWin = (globalThis as any).window + const beaconCalls: string[] = [] + const pageHideListeners: Array<() => void> = [] + ;(globalThis as any).navigator = { sendBeacon: (_url: string, data: string) => { beaconCalls.push(data); return true } } + ;(globalThis as any).window = { + onerror: null, + addEventListener: (event: string, fn: () => void, _opts?: any) => { + if (event === 'pagehide') pageHideListeners.push(fn) + } + } + try { + const reporter = new BeaconReporter({ url: '/logs', maxSize: 100, flushInterval: 0 }) + reporter.log('error', 'svc', T0, {}, ['critical']) + pageHideListeners.forEach(fn => fn()) + expect(beaconCalls).toHaveLength(1) + const entries = JSON.parse(beaconCalls[0]) + expect(entries[0].messages[0]).toBe('critical') + } finally { + ;(globalThis as any).navigator = origNav + ;(globalThis as any).window = origWin + } + }) + + it('does not throw when sendBeacon throws', () => { + const orig = (globalThis as any).navigator + ;(globalThis as any).navigator = { sendBeacon: () => { throw new Error('beacon failed') } } + try { + const reporter = new BeaconReporter({ url: '/logs', maxSize: 1, flushInterval: 0 }) + expect(() => reporter.log('error', 's', T0, {}, ['msg'])).not.toThrow() + } finally { + ;(globalThis as any).navigator = orig + } + }) + + it('respects factory level filtering', () => { + const orig = (globalThis as any).navigator + const calls: any[] = [] + ;(globalThis as any).navigator = { sendBeacon: (_url: string, data: string) => { calls.push(JSON.parse(data)); return true } } + try { + const reporter = new BeaconReporter({ url: '/logs', maxSize: 10, flushInterval: 0 }) + const factory = new LoggerFactory([reporter]) + factory.level = 'warning' + factory.getLogger('t').debug('dropped') + factory.getLogger('t').info('dropped') + factory.getLogger('t').warning('kept') + reporter.flush() + expect(calls).toHaveLength(1) + expect(calls[0]).toHaveLength(1) + expect(calls[0][0].level).toBe('warning') + } finally { + ;(globalThis as any).navigator = orig + } + }) + + it('captureErrors installs window.onerror that logs into the reporter', () => { + const origNav = (globalThis as any).navigator + const origWin = (globalThis as any).window + const beaconCalls: any[] = [] + ;(globalThis as any).navigator = { sendBeacon: (_url: string, data: string) => { beaconCalls.push(JSON.parse(data)); return true } } + const mockWindow: any = { onerror: null, addEventListener: () => {} } + ;(globalThis as any).window = mockWindow + try { + const reporter = new BeaconReporter({ url: '/logs', maxSize: 1, flushInterval: 0, captureErrors: true }) + mockWindow.onerror('Something went wrong', 'app.js', 10, 5, new Error('test')) + expect(beaconCalls).toHaveLength(1) + expect(beaconCalls[0][0].level).toBe('error') + expect(beaconCalls[0][0].scope).toBe('window') + } finally { + ;(globalThis as any).navigator = origNav + ;(globalThis as any).window = origWin + } + }) + + it('captureErrors custom errorScope is used for captured errors', () => { + const origNav = (globalThis as any).navigator + const origWin = (globalThis as any).window + const beaconCalls: any[] = [] + ;(globalThis as any).navigator = { sendBeacon: (_url: string, data: string) => { beaconCalls.push(JSON.parse(data)); return true } } + const mockWindow: any = { onerror: null, addEventListener: () => {} } + ;(globalThis as any).window = mockWindow + try { + const reporter = new BeaconReporter({ url: '/logs', maxSize: 1, flushInterval: 0, captureErrors: true, errorScope: 'global' }) + mockWindow.onerror('err', 'app.js', 1, 1, new Error('x')) + expect(beaconCalls[0][0].scope).toBe('global') + } finally { + ;(globalThis as any).navigator = origNav + ;(globalThis as any).window = origWin + } + }) + + it('captureErrors installs unhandledrejection listener', () => { + const origNav = (globalThis as any).navigator + const origWin = (globalThis as any).window + const beaconCalls: any[] = [] + ;(globalThis as any).navigator = { sendBeacon: (_url: string, data: string) => { beaconCalls.push(JSON.parse(data)); return true } } + const listeners: Array<(evt: any) => void> = [] + const mockWindow: any = { onerror: null, addEventListener: (_evt: string, fn: any) => listeners.push(fn) } + ;(globalThis as any).window = mockWindow + try { + const reporter = new BeaconReporter({ url: '/logs', maxSize: 1, flushInterval: 0, captureErrors: true }) + listeners.forEach(fn => fn({ reason: new Error('promise rejected') })) + expect(beaconCalls).toHaveLength(1) + expect(beaconCalls[0][0].level).toBe('error') + expect(beaconCalls[0][0].messages[0]).toBe('Unhandled promise rejection') + } finally { + ;(globalThis as any).navigator = origNav + ;(globalThis as any).window = origWin + } + }) + + it('close() flushes pending entries', () => { + const orig = (globalThis as any).navigator + const calls: any[] = [] + ;(globalThis as any).navigator = { sendBeacon: (_url: string, data: string) => { calls.push(JSON.parse(data)); return true } } + try { + const reporter = new BeaconReporter({ url: '/logs', maxSize: 100, flushInterval: 0 }) + reporter.log('info', 's', T0, {}, ['closing']) + reporter.close() + expect(calls).toHaveLength(1) + } finally { + ;(globalThis as any).navigator = orig + } + }) + +}) + +// --------------------------------------------------------------------------- +// IndexedDBReporter tests +// --------------------------------------------------------------------------- + +describe('IndexedDBReporter', () => { + + it('throws when indexedDB is absent', () => { + const orig = (globalThis as any).indexedDB + ;(globalThis as any).indexedDB = undefined + try { + expect(() => new IndexedDBReporter()).toThrow('IndexedDB') + } finally { + ;(globalThis as any).indexedDB = orig + } + }) + + it('stores log entries and drain() returns them', async () => { + const orig = (globalThis as any).indexedDB + ;(globalThis as any).indexedDB = makeFakeIDB() + try { + const reporter = new IndexedDBReporter() + reporter.log('info', 'svc', T0, {}, ['hello']) + reporter.log('warning', 'svc', T1, { reqId: 'r1' }, ['world']) + await reporter.flush() + const entries = await reporter.drain() + expect(entries).toHaveLength(2) + expect(entries[0].level).toBe('info') + expect(entries[0].scope).toBe('svc') + expect(entries[0].time).toBe(T0) + expect(entries[0].messages).toEqual(['hello']) + expect(entries[1].level).toBe('warning') + expect(entries[1].fields).toEqual({ reqId: 'r1' }) + } finally { + ;(globalThis as any).indexedDB = orig + } + }) + + it('drain() clears the store so a second drain returns empty', async () => { + const orig = (globalThis as any).indexedDB + ;(globalThis as any).indexedDB = makeFakeIDB() + try { + const reporter = new IndexedDBReporter() + reporter.log('info', 's', T0, {}, ['a']) + await reporter.flush() + await reporter.drain() + const second = await reporter.drain() + expect(second).toHaveLength(0) + } finally { + ;(globalThis as any).indexedDB = orig + } + }) + + it('flush() waits for all pending writes before resolving', async () => { + const orig = (globalThis as any).indexedDB + ;(globalThis as any).indexedDB = makeFakeIDB() + try { + const reporter = new IndexedDBReporter() + reporter.log('info', 's', T0, {}, ['a']) + reporter.log('info', 's', T0, {}, ['b']) + reporter.log('info', 's', T0, {}, ['c']) + await reporter.flush() + const entries = await reporter.drain() + expect(entries).toHaveLength(3) + } finally { + ;(globalThis as any).indexedDB = orig + } + }) + + it('evicts oldest entries when maxEntries is exceeded', async () => { + const orig = (globalThis as any).indexedDB + ;(globalThis as any).indexedDB = makeFakeIDB() + try { + const reporter = new IndexedDBReporter({ maxEntries: 2 }) + reporter.log('info', 's', T0, {}, ['first']) + reporter.log('info', 's', T0, {}, ['second']) + reporter.log('info', 's', T0, {}, ['third']) + await reporter.flush() + const entries = await reporter.drain() + expect(entries).toHaveLength(2) + const messages = entries.map(e => e.messages[0]) + expect(messages).not.toContain('first') + expect(messages).toContain('third') + } finally { + ;(globalThis as any).indexedDB = orig + } + }) + + it('close() stops accepting new entries', async () => { + const orig = (globalThis as any).indexedDB + ;(globalThis as any).indexedDB = makeFakeIDB() + try { + const reporter = new IndexedDBReporter() + reporter.log('info', 's', T0, {}, ['before-close']) + await reporter.close() + reporter.log('info', 's', T0, {}, ['after-close']) + const entries = await reporter.drain() + const messages = entries.map(e => e.messages[0]) + expect(messages).toContain('before-close') + expect(messages).not.toContain('after-close') + } finally { + ;(globalThis as any).indexedDB = orig + } + }) + + it('respects factory level filtering', async () => { + const orig = (globalThis as any).indexedDB + ;(globalThis as any).indexedDB = makeFakeIDB() + try { + const reporter = new IndexedDBReporter() + const factory = new LoggerFactory([reporter]) + factory.level = 'warning' + factory.getLogger('t').debug('dropped') + factory.getLogger('t').info('dropped') + factory.getLogger('t').warning('kept') + await reporter.flush() + const entries = await reporter.drain() + expect(entries).toHaveLength(1) + expect(entries[0].level).toBe('warning') + } finally { + ;(globalThis as any).indexedDB = orig + } + }) + + it('does not throw when IDB open fails', async () => { + const orig = (globalThis as any).indexedDB + ;(globalThis as any).indexedDB = { + open(_name: string, _version: number) { + const req: any = { result: null, error: new Error('open failed'), onupgradeneeded: null, onsuccess: null, onerror: null } + Promise.resolve().then(() => req.onerror?.({ target: req })) + return req + } + } + try { + const reporter = new IndexedDBReporter() + reporter.log('info', 's', T0, {}, ['msg']) + await expect(reporter.flush()).resolves.toBeUndefined() + } finally { + ;(globalThis as any).indexedDB = orig + } + }) + + it('drain() returns entries with correct fields shape', async () => { + const orig = (globalThis as any).indexedDB + ;(globalThis as any).indexedDB = makeFakeIDB() + try { + const reporter = new IndexedDBReporter() + reporter.log('error', 'auth', T1, { userId: 7 }, ['login failed', { code: 401 }]) + await reporter.flush() + const [entry] = await reporter.drain() + expect(entry.level).toBe('error') + expect(entry.scope).toBe('auth') + expect(entry.time).toBe(T1) + expect(entry.fields).toEqual({ userId: 7 }) + expect(entry.messages).toEqual(['login failed', { code: 401 }]) + } finally { + ;(globalThis as any).indexedDB = orig + } + }) + +}) + +describe('textFormatter', () => { + it('produces LEVEL [ISO] | scope: msg format', () => { + const line = textFormatter('info', 'auth', T0, {}, ['hello']) + expect(line).toBe('INFO [2026-01-01T00:00:00.000Z] | auth: hello') + }) + + it('uppercases the level token', () => { + expect(textFormatter('error', 's', T0, {}, ['e'])).toMatch(/^ERROR/) + expect(textFormatter('warning', 's', T0, {}, ['w'])).toMatch(/^WARNING/) + expect(textFormatter('verbose', 's', T0, {}, ['v'])).toMatch(/^VERBOSE/) + }) + + it('joins multiple messages with a space', () => { + const line = textFormatter('info', 's', T0, {}, ['hello', 'world']) + expect(line).toContain('hello world') + }) + + it('serializes plain objects to inline JSON', () => { + const line = textFormatter('info', 's', T0, {}, [{ key: 'val' }]) + expect(line).toContain('{"key":"val"}') + }) + + it('serializes Error instances using the stack', () => { + const err = new Error('boom') + const line = textFormatter('error', 's', T0, {}, [err]) + expect(line).toContain('boom') + }) + + it('ignores fields — they do not appear in the output', () => { + const line = textFormatter('info', 's', T0, { requestId: 'r1' }, ['msg']) + expect(line).not.toContain('requestId') + expect(line).not.toContain('r1') + }) +}) + +describe('jsonFormatter', () => { + it('produces valid parseable JSON', () => { + const line = jsonFormatter('info', 'auth', T0, {}, ['ok']) + expect(() => JSON.parse(line)).not.toThrow() + }) + + it('contains level, scope, time (number), and messages', () => { + const parsed = JSON.parse(jsonFormatter('info', 'auth', T0, {}, ['ok', { id: 7 }])) + expect(parsed.level).toBe('info') + expect(parsed.scope).toBe('auth') + expect(parsed.time).toBe(T0) + expect(parsed.messages).toEqual(['ok', { id: 7 }]) + }) + + it('spreads fields as top-level keys', () => { + const parsed = JSON.parse(jsonFormatter('info', 's', T0, { requestId: 'r1', userId: 42 }, ['msg'])) + expect(parsed.requestId).toBe('r1') + expect(parsed.userId).toBe(42) + expect(parsed.messages).toEqual(['msg']) + }) + + it('serializes Error instances in messages with name/message/stack', () => { + const parsed = JSON.parse(jsonFormatter('error', 's', T0, {}, [new Error('boom')])) + expect(parsed.messages[0].name).toBe('Error') + expect(parsed.messages[0].message).toBe('boom') + expect(typeof parsed.messages[0].stack).toBe('string') + }) + + it('converts BigInt values in messages to strings', () => { + const parsed = JSON.parse(jsonFormatter('info', 's', T0, {}, [42n])) + expect(parsed.messages[0]).toBe('42') + }) + + it('replaces circular references in messages with [Circular]', () => { + const cyc: any = {} + cyc.self = cyc + const parsed = JSON.parse(jsonFormatter('info', 's', T0, {}, [cyc])) + expect(parsed.messages[0].self).toBe('[Circular]') + }) + + it('keeps sibling fields around a circular reference', () => { + const obj: any = { name: 'node', status: 'ok' } + obj.loop = obj + const parsed = JSON.parse(jsonFormatter('info', 's', T0, {}, [obj])) + expect(parsed.messages[0].name).toBe('node') + expect(parsed.messages[0].status).toBe('ok') + expect(parsed.messages[0].loop).toBe('[Circular]') + }) +}) + +describe('logfmtFormatter', () => { + it('produces level, scope, ts, and msg fields', () => { + const line = logfmtFormatter('info', 'auth', T0, {}, ['hello']) + expect(line).toContain('level=info') + expect(line).toContain('scope=auth') + expect(line).toContain('ts=2026-01-01T00:00:00.000Z') + expect(line).toContain('msg=hello') + }) + + it('includes context fields as key=value pairs between ts and msg', () => { + const line = logfmtFormatter('info', 's', T0, { requestId: 'r1', userId: '42' }, ['event']) + expect(line).toContain('requestId=r1') + expect(line).toContain('userId=42') + const tsIdx = line.indexOf('ts=') + const msgIdx = line.indexOf('msg=') + const reqIdx = line.indexOf('requestId=') + expect(reqIdx).toBeGreaterThan(tsIdx) + expect(reqIdx).toBeLessThan(msgIdx) + }) + + it('joins multiple messages with a space in the msg field', () => { + const line = logfmtFormatter('info', 's', T0, {}, ['hello', 'world']) + expect(line).toContain('msg="hello world"') + }) + + it('quotes msg values that contain spaces', () => { + const line = logfmtFormatter('info', 's', T0, {}, ['hello world']) + expect(line).toContain('msg="hello world"') + }) + + it('quotes field values that contain spaces', () => { + const line = logfmtFormatter('info', 's', T0, { env: 'my env' }, ['x']) + expect(line).toContain('env="my env"') + }) + + it('quotes field values that contain = characters', () => { + const line = logfmtFormatter('info', 's', T0, { expr: 'a=b' }, ['x']) + expect(line).toContain('expr="a=b"') + }) + + it('produces "" for an empty msg', () => { + const line = logfmtFormatter('info', 's', T0, {}, ['']) + expect(line).toContain('msg=""') + }) + + it('serializes object messages as JSON within the msg field', () => { + const line = logfmtFormatter('info', 's', T0, {}, [{ id: 7 }]) + expect(line).toContain('{"id":7}') + }) + + it('uses error.message for Error instances in messages', () => { + const err = new Error('something went wrong') + const line = logfmtFormatter('error', 's', T0, {}, [err]) + expect(line).toContain('something went wrong') + }) +}) + +describe('JSONLineReporter — formatter option', () => { + it('uses jsonFormatter by default (parseable JSON with level/scope/time/messages)', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ write: line => lines.push(line) }) + reporter.log('info', 'auth', T0, {}, ['ok']) + const parsed = JSON.parse(lines[0]) + expect(parsed.level).toBe('info') + expect(parsed.scope).toBe('auth') + expect(parsed.time).toBe(T0) + expect(parsed.messages).toEqual(['ok']) + }) + + it('accepts a custom formatter — logfmt output over JSONLineReporter', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ write: line => lines.push(line), formatter: logfmtFormatter }) + reporter.log('info', 'auth', T0, {}, ['hello']) + expect(lines[0]).toContain('level=info') + expect(lines[0]).toContain('scope=auth') + expect(lines[0]).toContain('msg=hello') + }) + + it('merges extra into fields before passing to the formatter', () => { + const lines: string[] = [] + const reporter = new JSONLineReporter({ + write: line => lines.push(line), + extra: { service: 'api' }, + formatter: logfmtFormatter + }) + reporter.log('info', 's', T0, {}, ['x']) + expect(lines[0]).toContain('service=api') + }) +}) + +describe('ConsoleLogReporter — formatter option', () => { + it('uses the formatter to produce the full output line instead of the default prefix + messages', () => { + const reporter = new ConsoleLogReporter({ color: false, formatter: logfmtFormatter }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 'auth', T0, {}, ['hello']) + const call = spy.mock.calls[0][0] + expect(call).toContain('level=info') + expect(call).toContain('scope=auth') + expect(call).toContain('msg=hello') + expect(call).not.toContain('INFO [') + spy.mockRestore() + }) + + it('routes error level to console.error when formatter is set', () => { + const reporter = new ConsoleLogReporter({ color: false, formatter: textFormatter }) + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + reporter.log('error', 's', T0, {}, ['boom']) + expect(spy).toHaveBeenCalledTimes(1) + const line = spy.mock.calls[0][0] + expect(line).toContain('ERROR') + spy.mockRestore() + }) + + it('passes fields to the formatter when set', () => { + const reporter = new ConsoleLogReporter({ + color: false, + formatter: (level, scope, _time, fields, messages) => { + return `${level} ${scope} req=${fields.requestId} ${messages.join(' ')}` + } + }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + reporter.log('info', 'api', T0, { requestId: 'r1' }, ['start']) + expect(spy.mock.calls[0][0]).toBe('info api req=r1 start') + spy.mockRestore() + }) +}) + +describe('AsyncContext', () => { + it('getFields() returns {} when no context is active', () => { + const ctx = new AsyncContext() + expect(ctx.getFields()).toEqual({}) + }) + + it('run() makes fields available inside the callback', () => { + const ctx = new AsyncContext() + const result = ctx.run({ requestId: 'r1' }, () => ctx.getFields()) + expect(result).toEqual({ requestId: 'r1' }) + }) + + it('getFields() returns {} after run() exits', () => { + const ctx = new AsyncContext() + ctx.run({ requestId: 'r1' }, () => {}) + expect(ctx.getFields()).toEqual({}) + }) + + it('fields propagate across async continuations', async () => { + const ctx = new AsyncContext() + let captured: Record = {} + await ctx.run({ requestId: 'async-1' }, async () => { + await Promise.resolve() + captured = ctx.getFields() + }) + expect(captured).toEqual({ requestId: 'async-1' }) + }) + + it('nested run() merges parent fields, child wins on key conflict', () => { + const ctx = new AsyncContext() + let inner: Record = {} + ctx.run({ requestId: 'r1', userId: 1 }, () => { + ctx.run({ userId: 2, traceId: 't1' }, () => { + inner = ctx.getFields() + }) + }) + expect(inner).toEqual({ requestId: 'r1', userId: 2, traceId: 't1' }) + }) + + it('parent context is restored after nested run() exits', () => { + const ctx = new AsyncContext() + let outer: Record = {} + ctx.run({ requestId: 'r1' }, () => { + ctx.run({ traceId: 't1' }, () => {}) + outer = ctx.getFields() + }) + expect(outer).toEqual({ requestId: 'r1' }) + }) + + it('multiple independent AsyncContext instances do not interfere', () => { + const ctxA = new AsyncContext() + const ctxB = new AsyncContext() + let fieldsA: Record = {} + let fieldsB: Record = {} + ctxA.run({ source: 'a' }, () => { + ctxB.run({ source: 'b' }, () => { + fieldsA = ctxA.getFields() + fieldsB = ctxB.getFields() + }) + }) + expect(fieldsA).toEqual({ source: 'a' }) + expect(fieldsB).toEqual({ source: 'b' }) + }) +}) + +describe('ContextualReporter', () => { + it('merges ALS fields into log records', () => { + const captured: { fields: any }[] = [] + class Capture extends LogReporter { + log(_l: any, _s: any, _t: any, fields: any): void { captured.push({ fields }) } + } + const ctx = new AsyncContext() + const reporter = new ContextualReporter(new Capture(), ctx) + ctx.run({ requestId: 'r1' }, () => { + reporter.log('info', 's', T0, {}, ['msg']) + }) + expect(captured[0].fields).toEqual({ requestId: 'r1' }) + }) + + it('static fields (withContext) override ALS fields on key conflict', () => { + const captured: { fields: any }[] = [] + class Capture extends LogReporter { + log(_l: any, _s: any, _t: any, fields: any): void { captured.push({ fields }) } + } + const ctx = new AsyncContext() + const reporter = new ContextualReporter(new Capture(), ctx) + ctx.run({ requestId: 'r1', env: 'prod' }, () => { + reporter.log('info', 's', T0, { env: 'staging' }, ['msg']) + }) + expect(captured[0].fields).toEqual({ requestId: 'r1', env: 'staging' }) + }) + + it('passes through static fields unchanged when no ALS context is active', () => { + const captured: { fields: any }[] = [] + class Capture extends LogReporter { + log(_l: any, _s: any, _t: any, fields: any): void { captured.push({ fields }) } + } + const ctx = new AsyncContext() + const reporter = new ContextualReporter(new Capture(), ctx) + reporter.log('info', 's', T0, { requestId: 'r1' }, ['msg']) + expect(captured[0].fields).toEqual({ requestId: 'r1' }) + }) + + it('ALS fields surface in emitted records via the factory pipeline', async () => { + const captured: { fields: any }[] = [] + class Capture extends LogReporter { + log(_l: any, _s: any, _t: any, fields: any): void { captured.push({ fields }) } + } + const ctx = new AsyncContext() + const factory = new LoggerFactory([new ContextualReporter(new Capture(), ctx)]) + factory.level = 'info' + + await ctx.run({ requestId: 'req-1', traceId: 'trace-1' }, async () => { + await Promise.resolve() + factory.getLogger('handler').info('handled') + }) + + expect(captured).toHaveLength(1) + expect(captured[0].fields).toEqual({ requestId: 'req-1', traceId: 'trace-1' }) + }) + + it('withContext fields override ALS fields via factory pipeline', () => { + const captured: { fields: any }[] = [] + class Capture extends LogReporter { + log(_l: any, _s: any, _t: any, fields: any): void { captured.push({ fields }) } + } + const ctx = new AsyncContext() + const factory = new LoggerFactory([new ContextualReporter(new Capture(), ctx)]) + factory.level = 'info' + + ctx.run({ requestId: 'r1', env: 'prod' }, () => { + const log = factory.getLogger('svc').withContext({ env: 'staging' }) + log.info('event') + }) + + expect(captured[0].fields).toEqual({ requestId: 'r1', env: 'staging' }) + }) + + it('respects factory level filtering', () => { + const captured: any[] = [] + class Capture extends LogReporter { + log(_l: any, _s: any, _t: any, _f: any, msgs: any[]): void { captured.push(msgs[0]) } + } + const ctx = new AsyncContext() + const factory = new LoggerFactory([new ContextualReporter(new Capture(), ctx)]) + factory.level = 'warning' + ctx.run({ requestId: 'r1' }, () => { + factory.getLogger('t').debug('dropped') + factory.getLogger('t').warning('shown') + }) + expect(captured).toEqual(['shown']) + }) + + it('flush() delegates to the inner reporter', () => { + let flushed = false + class Inner extends LogReporter { + log(): void {} + flush(): void { flushed = true } + } + const ctx = new AsyncContext() + const reporter = new ContextualReporter(new Inner(), ctx) + reporter.flush() + expect(flushed).toBe(true) + }) + + it('close() delegates to the inner reporter', async () => { + let closed = false + class Inner extends LogReporter { + log(): void {} + close(): void { closed = true } + } + const ctx = new AsyncContext() + const reporter = new ContextualReporter(new Inner(), ctx) + await reporter.close() + expect(closed).toBe(true) + }) +}) + +describe('MemoryReporter', () => { + it('retains all entries without draining', () => { + const reporter = new MemoryReporter() + reporter.log('info', 's', T0, {}, ['a']) + reporter.log('error', 's', T0, {}, ['b']) + expect(reporter.entries()).toHaveLength(2) + expect(reporter.entries()).toHaveLength(2) + }) + + it('entries() returns all entries in insertion order', () => { + const reporter = new MemoryReporter() + const factory = new LoggerFactory([reporter], () => T0) + factory.level = 'verbose' + const log = factory.getLogger('app') + log.info('first') + log.warning('second') + log.error('third') + const entries = reporter.entries() + expect(entries).toHaveLength(3) + expect(entries[0].level).toBe('info') + expect(entries[0].messages).toEqual(['first']) + expect(entries[1].level).toBe('warning') + expect(entries[2].level).toBe('error') + }) + + it('entries() returns a copy, not the internal array', () => { + const reporter = new MemoryReporter() + reporter.log('info', 's', T0, {}, ['a']) + const snap1 = reporter.entries() + reporter.log('info', 's', T0, {}, ['b']) + expect(snap1).toHaveLength(1) + expect(reporter.entries()).toHaveLength(2) + }) + + it('find(level) returns only entries with the specified level', () => { + const reporter = new MemoryReporter() + reporter.log('info', 's', T0, {}, ['msg1']) + reporter.log('error', 's', T0, {}, ['err']) + reporter.log('info', 's', T0, {}, ['msg2']) + reporter.log('warning', 's', T0, {}, ['warn']) + const infos = reporter.find('info') + expect(infos).toHaveLength(2) + expect(infos[0].messages).toEqual(['msg1']) + expect(infos[1].messages).toEqual(['msg2']) + const errors = reporter.find('error') + expect(errors).toHaveLength(1) + expect(errors[0].messages).toEqual(['err']) + }) + + it('find(level) returns empty array when no entries match', () => { + const reporter = new MemoryReporter() + reporter.log('info', 's', T0, {}, ['msg']) + expect(reporter.find('debug')).toHaveLength(0) + }) + + it('clear() resets the reporter', () => { + const reporter = new MemoryReporter() + reporter.log('info', 's', T0, {}, ['a']) + reporter.log('error', 's', T0, {}, ['b']) + reporter.clear() + expect(reporter.entries()).toHaveLength(0) + expect(reporter.find('info')).toHaveLength(0) + reporter.log('debug', 's', T0, {}, ['c']) + expect(reporter.entries()).toHaveLength(1) + }) + + it('respects factory level filtering', () => { + const reporter = new MemoryReporter() + const factory = new LoggerFactory([reporter]) + factory.level = 'warning' + const log = factory.getLogger('t') + log.debug('dropped') + log.info('dropped') + log.warning('shown') + log.error('shown') + expect(reporter.entries()).toHaveLength(2) + expect(reporter.entries().map(e => e.level)).toEqual(['warning', 'error']) + }) + + it('captures scope and time from the factory', () => { + const reporter = new MemoryReporter() + const factory = new LoggerFactory([reporter], () => T0) + factory.getLogger('svc').info('hello') + const [entry] = reporter.entries() + expect(entry.scope).toBe('svc') + expect(entry.time).toBe(T0) + }) + + it('captures fields from withContext', () => { + const reporter = new MemoryReporter() + const factory = new LoggerFactory([reporter]) + const log = factory.getLogger('svc').withContext({ requestId: 'r1' }) + log.info('event') + const [entry] = reporter.entries() + expect(entry.fields).toEqual({ requestId: 'r1' }) + expect(entry.messages).toEqual(['event']) + }) }) diff --git a/logging/tsup.config.js b/logging/tsup.config.js index 9a4b9e23..49a31822 100644 --- a/logging/tsup.config.js +++ b/logging/tsup.config.js @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/main.ts', 'src/node.ts'], + entry: ['src/main.ts', 'src/node.ts', 'src/browser.ts'], format: ['cjs', 'esm'], dts: true, clean: true, diff --git a/nginxpilot/Dockerfile b/nginxpilot/Dockerfile index 9e7e81a3..178b47ff 100644 --- a/nginxpilot/Dockerfile +++ b/nginxpilot/Dockerfile @@ -17,11 +17,16 @@ RUN CGO_ENABLED=0 go build -trimpath \ # nginx base image: one container runs nginx AND the sync daemon, sharing # /var/lib/nginxpilot directly. git + openssh-client are required # for git sources (the daemon shells out to the system git binary); -# su-exec runs the daemon with group `nginx` so deployed files (0750/0640) -# stay readable by the nginx workers. +# su-exec runs the daemon as the unprivileged `nginxpilot` user (group `nginx`) +# so deployed files (0750/0640) stay readable by the nginx workers. FROM nginx:1.27-alpine RUN apk add --no-cache git openssh-client ca-certificates tzdata su-exec +RUN addgroup -S nginxpilot 2>/dev/null; \ + adduser -S -D -H -G nginx nginxpilot && \ + mkdir -p /var/lib/nginxpilot && \ + chown -R nginxpilot:nginx /var/lib/nginxpilot + COPY --from=build /out/nginxpilot /usr/local/bin/nginxpilot COPY docker/entrypoint.sh /usr/local/bin/nginxpilot-entrypoint.sh RUN chmod +x /usr/local/bin/nginxpilot-entrypoint.sh @@ -35,6 +40,11 @@ VOLUME /var/lib/nginxpilot # in config to expose it beyond the container's loopback). EXPOSE 80 9090 +# Requires admin.listen to be non-empty (default: 127.0.0.1:9090). +# Disabling the admin endpoint (admin.listen: "") also disables this healthcheck. +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:9090/healthz >/dev/null 2>&1 || exit 1 + # No arguments → supervise daemon + nginx. With arguments → run the CLI: # docker run --rm ... validate ENTRYPOINT ["/usr/local/bin/nginxpilot-entrypoint.sh"] diff --git a/nginxpilot/README.md b/nginxpilot/README.md index c0ce253a..ba423d12 100644 --- a/nginxpilot/README.md +++ b/nginxpilot/README.md @@ -70,8 +70,8 @@ sites: branch: main interval: 2m # min 30s; default from defaults.interval auth: - method: ssh-key # ssh-key | https-token | none - key_file: /etc/nginxpilot/keys/example_ed25519 + method: ssh-key # ssh-key | https-token | github-token | none + key_file: /etc/nginxpilot/keys/example_ed25519 # or key_env: SSH_KEY (key material in an env var) # known_hosts: /etc/nginxpilot/known_hosts # strict; default accept-new (TOFU) subdir: dist/ # serve only this subtree require_file: [index.html] # opt-in post-fetch gate @@ -80,6 +80,64 @@ sites: Clones are shallow + single-branch through the system `git` binary; the bare cache under `data_dir/cache/git/` is disposable. +**git auth methods** (all over an `https://` URL except `ssh-key`): + +| `auth.method` | Needs | Use when | +|---|---|---| +| `ssh-key` | `key_file` **or** `key_env` (+ optional `known_hosts`) | `git@`/`ssh://` URL, deploy key | +| `https-token` | `username` + `token_env`/`token_file` | classic user:token HTTPS | +| `github-token` | `token_env`/`token_file` (no username) | a GitHub token from a social/web login | +| `none` | — | public repo | + +#### github-token — token-only GitHub auth + +For a private GitHub repo where you only have a token (no SSH key, no separate username) — e.g. one minted from a web/social login. The token alone authenticates: + +```yaml +sites: + - domain: app.example.com + source: + type: git + url: https://github.com/acme/private-site.git # https:// (not git@) + branch: main + auth: + method: github-token + token_env: GITHUB_TOKEN # or token_file: /run/secrets/gh_token +``` + +Get the token however you log in to GitHub: + +```bash +gh auth login # browser/social login flow +export GITHUB_TOKEN=$(gh auth token) +nginxpilot sync app.example.com +``` + +A fine-grained or classic **Personal Access Token** (Contents: read) and OAuth/GitHub-App installation tokens all work the same way. The token is sent as `Authorization: Basic base64("x-access-token:")` (GitHub's convention for OAuth/app tokens), injected via `GIT_CONFIG_*` so it never appears in `argv`, process listings, or on disk. Unlike `https-token`, no `auth.username` is set (supplying one is a validation error). + +#### ssh-key via `key_env` — supply the key with config (no staging) + +`ssh-key` takes the private key either by path (`key_file`) or by reference to an env var holding the **key material** (`key_env`) — exactly one. `key_env` suits containers: a `key_file` mounted from the host carries the host uid and `0600`, so the unprivileged daemon can't read it (and `validate` rejects a key not owned by the daemon user). With `key_env` the daemon writes the key to a `0600` temp file it owns under `data_dir/tmp` at sync time and deletes it afterward — no host-side `chown`/staging. + +```yaml +source: + type: git + url: git@github.com:acme/private.git + branch: main + auth: + method: ssh-key + key_env: SSH_KEY # env var holds the PEM, not a path +``` +```bash +docker run -d \ + -e SSH_KEY="$(cat ~/.ssh/id_ed25519)" \ + -v /etc/nginxpilot:/etc/nginxpilot:ro \ + -v nginxpilot-sites:/var/lib/nginxpilot \ + ghcr.io/kalevski/toolcase/nginxpilot:latest +``` + +Inline key material in the config (`auth.key: |`) is a parse-time error, same as other inline secrets — use `key_env` (or `key_file`). + ### http-zip source ```yaml @@ -155,7 +213,7 @@ docker run -d \ ``` - Mount your vhosts into `/etc/nginx/conf.d/`, each `root` pointing at `…/sites//current` (generate a starting snippet with `print-vhost`, below). -- The daemon runs with group `nginx` so its `0750`/`0640` content stays readable by the workers; nginx is PID-managed by the official entrypoint, content swaps need no reload. +- The daemon runs as the unprivileged `nginxpilot` user (member of group `nginx`) so its `0750`/`0640` content stays readable by the workers; nginx is PID-managed by the official entrypoint, content swaps need no reload. - Port 9090 is the admin endpoint — set `admin.listen: 0.0.0.0:9090` in the config and publish the port if you want `/status` from outside. - Any argument bypasses the supervisor and runs the CLI directly: diff --git a/nginxpilot/cmd/nginxpilot/run.go b/nginxpilot/cmd/nginxpilot/run.go index e1e2a4fd..3a698805 100644 --- a/nginxpilot/cmd/nginxpilot/run.go +++ b/nginxpilot/cmd/nginxpilot/run.go @@ -7,6 +7,7 @@ import ( "net" "os" "os/signal" + "path/filepath" "syscall" "github.com/kalevski/toolcase/nginxpilot/internal/admin" @@ -40,6 +41,12 @@ func cmdRun(args []string) int { return 1 } + tmpDir := filepath.Join(cfg.DataDir, "tmp") + if err := os.RemoveAll(tmpDir); err != nil { + log.Warn("could not clear tmp dir on startup", "dir", tmpDir, "error", err) + } + _ = os.MkdirAll(tmpDir, 0o750) + store, err := state.NewStore(cfg.DataDir) if err != nil { log.Error("state store init failed", "error", err) @@ -58,9 +65,10 @@ func cmdRun(args []string) int { defer stop() // Admin endpoint (loopback by default; empty listen disables). - token := "" - if cfg.Admin.TokenEnv != "" { - token = os.Getenv(cfg.Admin.TokenEnv) + token, err := resolveAdminToken(cfg.Admin.TokenEnv, cfg.Admin.TokenFile) + if err != nil { + log.Error("admin token misconfiguration; refusing to start", "error", err) + return 1 } adminSrv := admin.New(mgr, token, log) go func() { @@ -99,6 +107,24 @@ func cmdRun(args []string) int { return 0 } +// resolveAdminToken resolves the bearer token from an env var or a secret file. +// When both refs are empty, no auth is configured and ("", nil) is returned. +// If a ref is configured but resolves to an empty value, an error is returned +// so the caller refuses to start rather than expose an unauthenticated endpoint. +func resolveAdminToken(tokenEnv, tokenFile string) (string, error) { + if tokenEnv == "" && tokenFile == "" { + return "", nil + } + token, err := config.ResolveSecret(tokenEnv, tokenFile) + if err != nil { + return "", err + } + if token == "" { + return "", fmt.Errorf("admin token is empty") + } + return token, nil +} + // sdNotify implements the systemd Type=notify readiness protocol with no // external dependency. No-op outside systemd. func sdNotify(msg string) { diff --git a/nginxpilot/cmd/nginxpilot/run_test.go b/nginxpilot/cmd/nginxpilot/run_test.go new file mode 100644 index 00000000..b5893d71 --- /dev/null +++ b/nginxpilot/cmd/nginxpilot/run_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveAdminToken(t *testing.T) { + t.Run("no refs configured returns empty string no error", func(t *testing.T) { + got, err := resolveAdminToken("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("want empty string, got %q", got) + } + }) + + t.Run("token_env set and variable has value returns token", func(t *testing.T) { + t.Setenv("TEST_ADMIN_TOKEN_SET", "supersecret") + got, err := resolveAdminToken("TEST_ADMIN_TOKEN_SET", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "supersecret" { + t.Fatalf("want %q, got %q", "supersecret", got) + } + }) + + t.Run("token_env set but variable unset returns error", func(t *testing.T) { + _, err := resolveAdminToken("TEST_ADMIN_TOKEN_MISSING_XYZ", "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("token_env set but variable empty returns error", func(t *testing.T) { + t.Setenv("TEST_ADMIN_TOKEN_EMPTY", "") + _, err := resolveAdminToken("TEST_ADMIN_TOKEN_EMPTY", "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("token_file with 0600 returns token", func(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "token") + if err := os.WriteFile(f, []byte("filetoken\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := resolveAdminToken("", f) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "filetoken" { + t.Fatalf("want %q, got %q", "filetoken", got) + } + }) + + t.Run("token_file with 0644 rejected", func(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "token") + if err := os.WriteFile(f, []byte("filetoken"), 0o644); err != nil { + t.Fatal(err) + } + _, err := resolveAdminToken("", f) + if err == nil { + t.Fatal("expected error for too-permissive secret file, got nil") + } + }) +} diff --git a/nginxpilot/cmd/nginxpilot/status.go b/nginxpilot/cmd/nginxpilot/status.go index b3dc3c47..31f7ac02 100644 --- a/nginxpilot/cmd/nginxpilot/status.go +++ b/nginxpilot/cmd/nginxpilot/status.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "io" + "net" "net/http" "os" "text/tabwriter" @@ -32,6 +33,7 @@ func cmdStatus(args []string) int { fmt.Fprintln(os.Stderr, "admin endpoint is disabled (admin.listen is empty); status unavailable") return 1 } + listen = normalizeListenAddr(listen) req, err := http.NewRequest(http.MethodGet, "http://"+listen+"/status", nil) if err != nil { @@ -99,9 +101,18 @@ func orDash(s string) string { return s } +func normalizeListenAddr(listen string) string { + host, port, err := net.SplitHostPort(listen) + if err == nil && (host == "" || host == "0.0.0.0" || host == "::") { + listen = net.JoinHostPort("127.0.0.1", port) + } + return listen +} + func truncate(s string, n int) string { - if len(s) <= n { + r := []rune(s) + if len(r) <= n { return s } - return s[:n-1] + "…" + return string(r[:n-1]) + "…" } diff --git a/nginxpilot/cmd/nginxpilot/status_test.go b/nginxpilot/cmd/nginxpilot/status_test.go new file mode 100644 index 00000000..94af8fb6 --- /dev/null +++ b/nginxpilot/cmd/nginxpilot/status_test.go @@ -0,0 +1,100 @@ +package main + +import "testing" + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + s string + n int + want string + }{ + { + name: "short string unchanged", + s: "hello", + n: 10, + want: "hello", + }, + { + name: "exact length unchanged", + s: "hello", + n: 5, + want: "hello", + }, + { + name: "long ASCII truncated with ellipsis", + s: "abcdefghij", + n: 6, + want: "abcde…", + }, + { + name: "multi-byte runes not split", + s: "héllo wörld", + n: 6, + want: "héllo…", + }, + { + name: "CJK runes not split", + s: "日本語テスト", + n: 4, + want: "日本語…", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := truncate(tc.s, tc.n) + if got != tc.want { + t.Fatalf("truncate(%q, %d) = %q, want %q", tc.s, tc.n, got, tc.want) + } + }) + } +} + +func TestNormalizeListenAddr(t *testing.T) { + tests := []struct { + name string + listen string + want string + }{ + { + name: "empty host rewritten to 127.0.0.1", + listen: ":9090", + want: "127.0.0.1:9090", + }, + { + name: "0.0.0.0 rewritten to 127.0.0.1", + listen: "0.0.0.0:9090", + want: "127.0.0.1:9090", + }, + { + name: ":: rewritten to 127.0.0.1", + listen: "[::]:9090", + want: "127.0.0.1:9090", + }, + { + name: "specific host left unchanged", + listen: "192.168.1.1:9090", + want: "192.168.1.1:9090", + }, + { + name: "localhost left unchanged", + listen: "localhost:9090", + want: "localhost:9090", + }, + { + name: "127.0.0.1 left unchanged", + listen: "127.0.0.1:9090", + want: "127.0.0.1:9090", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := normalizeListenAddr(tc.listen) + if got != tc.want { + t.Fatalf("normalizeListenAddr(%q) = %q, want %q", tc.listen, got, tc.want) + } + }) + } +} diff --git a/nginxpilot/cmd/nginxpilot/sync.go b/nginxpilot/cmd/nginxpilot/sync.go index 5b592ec8..1b972a9a 100644 --- a/nginxpilot/cmd/nginxpilot/sync.go +++ b/nginxpilot/cmd/nginxpilot/sync.go @@ -54,14 +54,14 @@ func cmdSync(args []string) int { fmt.Fprintf(os.Stderr, "state store init failed: %v\n", err) return 1 } - dep := deploy.New(cfg.DataDir, cfg.Defaults.KeepReleases, log) + dep := deploy.New(cfg.DataDir, log) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) defer stop() ctx, cancel := context.WithTimeout(ctx, *timeout) defer cancel() - if _, err := manager.SyncSite(ctx, *site, cfg.DataDir, store, dep, log); err != nil { + if _, err := manager.SyncSite(ctx, *site, cfg.Defaults, cfg.DataDir, store, dep, log); err != nil { fmt.Fprintf(os.Stderr, "sync failed: %v\n", err) return 1 } diff --git a/nginxpilot/cmd/nginxpilot/validate.go b/nginxpilot/cmd/nginxpilot/validate.go index 765a7a67..774c8dbd 100644 --- a/nginxpilot/cmd/nginxpilot/validate.go +++ b/nginxpilot/cmd/nginxpilot/validate.go @@ -65,6 +65,12 @@ func cmdValidate(args []string) int { } } + if cfg.Admin.TokenEnv != "" || cfg.Admin.TokenFile != "" { + if err := config.CheckSecretRef(cfg.Admin.TokenEnv, cfg.Admin.TokenFile); err != nil { + fail("admin token: %v", err) + } + } + if failed { return 1 } diff --git a/nginxpilot/docker/entrypoint.sh b/nginxpilot/docker/entrypoint.sh index aa67436d..215f9253 100644 --- a/nginxpilot/docker/entrypoint.sh +++ b/nginxpilot/docker/entrypoint.sh @@ -14,10 +14,10 @@ set -eu NGINXPILOT_CONFIG="${NGINXPILOT_CONFIG:-/etc/nginxpilot/config.yml}" if [ "$#" -gt 0 ]; then - exec su-exec 0:nginx nginxpilot "$@" + exec su-exec nginxpilot:nginx nginxpilot "$@" fi -su-exec 0:nginx nginxpilot run --config "$NGINXPILOT_CONFIG" & +su-exec nginxpilot:nginx nginxpilot run --config "$NGINXPILOT_CONFIG" & nginxpilot_pid=$! # Official nginx entrypoint (envsubst templates, ipv6 detection) then nginx. diff --git a/nginxpilot/internal/admin/admin.go b/nginxpilot/internal/admin/admin.go index 06abb246..ded208f8 100644 --- a/nginxpilot/internal/admin/admin.go +++ b/nginxpilot/internal/admin/admin.go @@ -28,7 +28,10 @@ type Server struct { log *slog.Logger } -// New builds the admin server. token may be empty (no auth). +// New builds the admin server. token may be empty only when no auth is +// configured (admin.token_env is unset). Callers must not pass an empty token +// when a token was expected — use resolveAdminToken in cmd/nginxpilot/run.go +// to enforce that invariant before constructing the server. func New(mgr *manager.Manager, token string, log *slog.Logger) *Server { return &Server{mgr: mgr, token: token, log: log} } diff --git a/nginxpilot/internal/config/secrets.go b/nginxpilot/internal/config/secrets.go index 174cceba..00e35f6d 100644 --- a/nginxpilot/internal/config/secrets.go +++ b/nginxpilot/internal/config/secrets.go @@ -76,5 +76,6 @@ func (a Auth) SecretRefs() [][2]string { add(a.TokenEnv, a.TokenFile) add(a.PasswordEnv, a.PasswordFile) add(a.ValueEnv, a.ValueFile) + add(a.KeyEnv, "") // key_file is path-checked separately (CheckSecretFile) return refs } diff --git a/nginxpilot/internal/config/secrets_test.go b/nginxpilot/internal/config/secrets_test.go new file mode 100644 index 00000000..1b604163 --- /dev/null +++ b/nginxpilot/internal/config/secrets_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "os" + "testing" +) + +func TestCheckSecretFile(t *testing.T) { + tests := []struct { + name string + perm os.FileMode + wantErr bool + }{ + {name: "0600 passes", perm: 0o600, wantErr: false}, + {name: "0640 passes", perm: 0o640, wantErr: false}, + {name: "0400 passes", perm: 0o400, wantErr: false}, + {name: "0440 passes", perm: 0o440, wantErr: false}, + {name: "0644 rejected", perm: 0o644, wantErr: true}, + {name: "0666 rejected", perm: 0o666, wantErr: true}, + {name: "0777 rejected", perm: 0o777, wantErr: true}, + {name: "0604 rejected", perm: 0o604, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "secret-*") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString("secret"); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := os.Chmod(f.Name(), tc.perm); err != nil { + t.Fatal(err) + } + + err = CheckSecretFile(f.Name()) + if tc.wantErr && err == nil { + t.Fatalf("mode %04o: expected error, got nil", tc.perm) + } + if !tc.wantErr && err != nil { + t.Fatalf("mode %04o: unexpected error: %v", tc.perm, err) + } + }) + } +} + +func TestCheckSecretFileMissing(t *testing.T) { + err := CheckSecretFile(t.TempDir() + "/nonexistent.key") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestCheckSecretFileDirectory(t *testing.T) { + dir := t.TempDir() + // os.Stat reports a directory, not a regular file. + err := CheckSecretFile(dir) + if err == nil { + t.Fatal("expected error for directory, got nil") + } +} diff --git a/nginxpilot/internal/config/types.go b/nginxpilot/internal/config/types.go index 8b2acb3c..f44c416d 100644 --- a/nginxpilot/internal/config/types.go +++ b/nginxpilot/internal/config/types.go @@ -5,6 +5,7 @@ package config import ( "fmt" + "math" "regexp" "strconv" "strings" @@ -21,12 +22,13 @@ const ( // Auth methods. const ( - AuthNone = "none" - AuthSSHKey = "ssh-key" - AuthHTTPSToken = "https-token" - AuthBearer = "bearer" - AuthBasic = "basic" - AuthHeader = "header" + AuthNone = "none" + AuthSSHKey = "ssh-key" + AuthHTTPSToken = "https-token" + AuthGitHubToken = "github-token" + AuthBearer = "bearer" + AuthBasic = "basic" + AuthHeader = "header" ) // Config is the merged result of the main file and all included fragments. @@ -46,8 +48,9 @@ type Config struct { type Admin struct { // Listen is the address for the admin endpoint. nil means the default // (127.0.0.1:9090); an explicit empty string disables the endpoint. - Listen *string `yaml:"listen"` - TokenEnv string `yaml:"token_env"` + Listen *string `yaml:"listen"` + TokenEnv string `yaml:"token_env"` + TokenFile string `yaml:"token_file"` } // ListenAddr resolves the effective admin listen address ("" = disabled). @@ -58,6 +61,10 @@ func (a Admin) ListenAddr() string { return *a.Listen } +// defaultKeepReleases is the built-in fallback when neither the site nor +// defaults specify keep_releases. +const defaultKeepReleases = 5 + // Defaults holds global per-site fallbacks. type Defaults struct { Interval Duration `yaml:"interval"` @@ -86,12 +93,25 @@ func (s Site) Interval(d Defaults) time.Duration { return 5 * time.Minute } +// KeepReleases returns the effective keep_releases for the site: per-site +// value wins, then defaults, then the built-in constant. +func (s Site) KeepReleases(d Defaults) int { + if s.Source.KeepReleases != nil && *s.Source.KeepReleases > 0 { + return *s.Source.KeepReleases + } + if d.KeepReleases > 0 { + return d.KeepReleases + } + return defaultKeepReleases +} + // Source describes where a site's content comes from. type Source struct { - Type string `yaml:"type"` - URL string `yaml:"url"` - Interval Duration `yaml:"interval"` - Auth Auth `yaml:"auth"` + Type string `yaml:"type"` + URL string `yaml:"url"` + Interval Duration `yaml:"interval"` + KeepReleases *int `yaml:"keep_releases"` + Auth Auth `yaml:"auth"` // git only Branch string `yaml:"branch"` @@ -129,8 +149,12 @@ func stripOrMinusOne(p *int) int { type Auth struct { Method string `yaml:"method"` - // ssh-key + // ssh-key: supply the private key either by path (key_file) or by + // reference to an env var holding the key material (key_env). Exactly + // one is required. key_env materializes the key into a daemon-owned + // 0600 temp file at sync time — no on-host staging/ownership dance. KeyFile string `yaml:"key_file"` + KeyEnv string `yaml:"key_env"` KnownHosts string `yaml:"known_hosts"` // https-token / basic @@ -154,6 +178,7 @@ type Auth struct { Token string `yaml:"token"` Password string `yaml:"password"` Value string `yaml:"value"` + Key string `yaml:"key"` } // MethodOrNone returns the effective auth method. @@ -272,7 +297,11 @@ func ParseByteSize(s string) (ByteSize, error) { case "TIB": mult = 1 << 40 } - return ByteSize(n * mult), nil + prod := n * mult + if prod < 0 || prod > float64(math.MaxInt64) { + return 0, fmt.Errorf("size %q is too large", s) + } + return ByteSize(prod), nil } // String implements fmt.Stringer. diff --git a/nginxpilot/internal/config/types_test.go b/nginxpilot/internal/config/types_test.go new file mode 100644 index 00000000..0c3f4941 --- /dev/null +++ b/nginxpilot/internal/config/types_test.go @@ -0,0 +1,91 @@ +package config + +import ( + "testing" +) + +func intPtr(v int) *int { return &v } + +func TestSiteKeepReleases(t *testing.T) { + tests := []struct { + name string + siteKeep *int + defaultKeep int + want int + }{ + { + name: "per-site value wins over defaults and built-in", + siteKeep: intPtr(7), + defaultKeep: 3, + want: 7, + }, + { + name: "falls back to defaults when site value is nil", + siteKeep: nil, + defaultKeep: 3, + want: 3, + }, + { + name: "falls back to defaults when site value is zero", + siteKeep: intPtr(0), + defaultKeep: 3, + want: 3, + }, + { + name: "falls back to built-in when both site and defaults are zero", + siteKeep: nil, + defaultKeep: 0, + want: defaultKeepReleases, + }, + { + name: "falls back to built-in when site value is zero and defaults is zero", + siteKeep: intPtr(0), + defaultKeep: 0, + want: defaultKeepReleases, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + site := Site{Source: Source{KeepReleases: tc.siteKeep}} + defaults := Defaults{KeepReleases: tc.defaultKeep} + got := site.KeepReleases(defaults) + if got != tc.want { + t.Errorf("KeepReleases() = %d, want %d", got, tc.want) + } + }) + } +} + +func TestParseByteSize(t *testing.T) { + tests := []struct { + input string + want ByteSize + wantErr bool + }{ + {"512MiB", 512 << 20, false}, + {"2GiB", 2 << 30, false}, + {"1048576", 1 << 20, false}, + {"0", 0, false}, + {"1B", 1, false}, + {"notasize", 0, true}, + {"99999999TiB", 0, true}, + } + + for _, tc := range tests { + got, err := ParseByteSize(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("ParseByteSize(%q): expected error, got %d", tc.input, got) + } + continue + } + if err != nil { + t.Errorf("ParseByteSize(%q): unexpected error: %v", tc.input, err) + continue + } + if got != tc.want { + t.Errorf("ParseByteSize(%q): got %d, want %d", tc.input, got, tc.want) + } + } +} diff --git a/nginxpilot/internal/config/validate.go b/nginxpilot/internal/config/validate.go index 6198da4a..4c5cd9d3 100644 --- a/nginxpilot/internal/config/validate.go +++ b/nginxpilot/internal/config/validate.go @@ -27,6 +27,10 @@ func Validate(cfg *Config) error { return fmt.Errorf("defaults.keep_releases must be >= 1") } + if cfg.Admin.TokenEnv != "" && cfg.Admin.TokenFile != "" { + return fmt.Errorf("admin.token_env and admin.token_file are mutually exclusive") + } + seen := map[string]string{} // domain -> file for i := range cfg.Sites { site := &cfg.Sites[i] @@ -109,8 +113,10 @@ func validateGitSource(src *Source) error { if !sshURL { return fmt.Errorf("auth.method ssh-key requires a git@/ssh:// URL") } - if src.Auth.KeyFile == "" { - return fmt.Errorf("auth.key_file is required for ssh-key auth") + // The private key comes either from a path (key_file) or from an + // env var holding the key material (key_env) — exactly one. + if err := exactlyOneRef("key", src.Auth.KeyEnv, src.Auth.KeyFile); err != nil { + return err } case AuthHTTPSToken: if !httpsURL { @@ -122,8 +128,22 @@ func validateGitSource(src *Source) error { if err := exactlyOneRef("token", src.Auth.TokenEnv, src.Auth.TokenFile); err != nil { return err } + case AuthGitHubToken: + // Token-only: the GitHub access token alone authenticates (no + // username). Suits a token minted from a social/web login — + // `gh auth login` then `gh auth token`, a Personal Access Token, + // or an OAuth/app token. + if !httpsURL { + return fmt.Errorf("auth.method github-token requires an https:// URL") + } + if src.Auth.Username != "" { + return fmt.Errorf("auth.username must not be set for github-token (the token alone authenticates)") + } + if err := exactlyOneRef("token", src.Auth.TokenEnv, src.Auth.TokenFile); err != nil { + return err + } default: - return fmt.Errorf("auth.method %q: git sources support ssh-key | https-token | none", src.Auth.Method) + return fmt.Errorf("auth.method %q: git sources support ssh-key | https-token | github-token | none", src.Auth.Method) } return nil } @@ -178,7 +198,7 @@ func validateHTTPZipSource(src *Source) error { // checkNoInlineSecrets turns inline secret keys into a targeted parse-time // error — config files must stay safe to commit (spec Q9). func checkNoInlineSecrets(a Auth) error { - for key, val := range map[string]string{"token": a.Token, "password": a.Password, "value": a.Value} { + for key, val := range map[string]string{"token": a.Token, "password": a.Password, "value": a.Value, "key": a.Key} { if val != "" { return fmt.Errorf("inline secrets are not allowed: use auth.%s_env or auth.%s_file instead of auth.%s", key, key, key) } diff --git a/nginxpilot/internal/config/validate_test.go b/nginxpilot/internal/config/validate_test.go new file mode 100644 index 00000000..7505390d --- /dev/null +++ b/nginxpilot/internal/config/validate_test.go @@ -0,0 +1,562 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +func minValidConfig() Config { + return Config{ + LogLevel: "info", + DataDir: "/tmp", + Defaults: Defaults{KeepReleases: 1}, + } +} + +func TestValidateAdminTokenMutualExclusion(t *testing.T) { + cfg := minValidConfig() + cfg.Admin.TokenEnv = "SOME_ENV" + cfg.Admin.TokenFile = "/some/file" + + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error when both token_env and token_file are set, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected 'mutually exclusive' in error, got: %v", err) + } +} + +func TestValidateAdminTokenEnvOnly(t *testing.T) { + cfg := minValidConfig() + cfg.Admin.TokenEnv = "SOME_ENV" + // token_file unset — should not error from mutual exclusion check + err := Validate(&cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateAdminTokenFileOnly(t *testing.T) { + cfg := minValidConfig() + cfg.Admin.TokenFile = "/some/file" + // token_env unset — should not error from mutual exclusion check + err := Validate(&cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ---- interval ------------------------------------------------------------- + +func TestValidateDefaultsMinInterval(t *testing.T) { + cfg := minValidConfig() + cfg.Defaults.Interval = Duration(10 * time.Second) // below 30 s minimum + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for defaults.interval below minimum, got nil") + } + if !strings.Contains(err.Error(), "minimum") { + t.Fatalf("expected 'minimum' in error, got: %v", err) + } +} + +func TestValidateSiteMinInterval(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/artifact.zip", + Interval: Duration(5 * time.Second), + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for site interval below minimum, got nil") + } + if !strings.Contains(err.Error(), "minimum") { + t.Fatalf("expected 'minimum' in error, got: %v", err) + } +} + +func TestValidateIntervalAtMinimumOK(t *testing.T) { + cfg := minValidConfig() + cfg.Defaults.Interval = Duration(MinInterval) + if err := Validate(&cfg); err != nil { + t.Fatalf("unexpected error at exactly MinInterval: %v", err) + } +} + +// ---- duplicate domains ---------------------------------------------------- + +func TestValidateDuplicateDomain(t *testing.T) { + cfg := minValidConfig() + site := Site{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/a.zip", + }, + File: "site-a.yaml", + } + cfg.Sites = []Site{site, { + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/b.zip", + }, + File: "site-b.yaml", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for duplicate domain, got nil") + } + if !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected 'duplicate' in error, got: %v", err) + } +} + +// ---- IDNA normalization --------------------------------------------------- + +func TestValidateIDNANormalization(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "münchen.de", // Unicode domain + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/a.zip", + }, + File: "test", + }} + if err := Validate(&cfg); err != nil { + t.Fatalf("unexpected error for unicode domain: %v", err) + } + // Domain must be normalized to punycode. + if cfg.Sites[0].Domain == "münchen.de" { + t.Errorf("domain was not converted to punycode: still %q", cfg.Sites[0].Domain) + } + if !strings.HasPrefix(cfg.Sites[0].Domain, "xn--") { + t.Errorf("expected punycode domain starting with xn--, got %q", cfg.Sites[0].Domain) + } +} + +// ---- inline secret traps -------------------------------------------------- + +func TestValidateInlineTokenRejected(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/a.zip", + Auth: Auth{Method: AuthBearer, Token: "secret123"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for inline token, got nil") + } + if !strings.Contains(err.Error(), "inline") { + t.Fatalf("expected 'inline' in error, got: %v", err) + } +} + +func TestValidateInlinePasswordRejected(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/a.zip", + Auth: Auth{Method: AuthBasic, Username: "user", Password: "s3cr3t"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for inline password, got nil") + } + if !strings.Contains(err.Error(), "inline") { + t.Fatalf("expected 'inline' in error, got: %v", err) + } +} + +// ---- git auth matrix ------------------------------------------------------ + +func TestValidateGitSSHKeyRequiresKeyFile(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "git@github.com:example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthSSHKey}, // no key_file + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for missing key_file, got nil") + } + if !strings.Contains(err.Error(), "key_file") { + t.Fatalf("expected key_file error, got: %v", err) + } +} + +func TestValidateGitSSHKeyRequiresSSHURL(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "https://github.com/example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthSSHKey, KeyFile: "/id_rsa"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for ssh-key with https URL, got nil") + } + if !strings.Contains(err.Error(), "ssh-key") { + t.Fatalf("expected ssh-key error, got: %v", err) + } +} + +func TestValidateGitHTTPSTokenRequiresUsername(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "https://github.com/example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthHTTPSToken, TokenEnv: "MY_TOKEN"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for missing username, got nil") + } + if !strings.Contains(err.Error(), "username") { + t.Fatalf("expected username error, got: %v", err) + } +} + +func TestValidateGitNoneWithSSHURLRejected(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "git@github.com:example/repo.git", + Branch: "main", + // no auth — but ssh URL requires ssh-key + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for ssh URL with no auth, got nil") + } +} + +func TestValidateGitHTTPSTokenOK(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "https://github.com/example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthHTTPSToken, Username: "git", TokenEnv: "MY_TOKEN"}, + }, + File: "test", + }} + if err := Validate(&cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateGitGitHubTokenOK(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "https://github.com/example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthGitHubToken, TokenEnv: "GH_TOKEN"}, + }, + File: "test", + }} + if err := Validate(&cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateGitGitHubTokenRejectsUsername(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "https://github.com/example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthGitHubToken, Username: "git", TokenEnv: "GH_TOKEN"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for username set with github-token, got nil") + } + if !strings.Contains(err.Error(), "username") { + t.Fatalf("expected username error, got: %v", err) + } +} + +func TestValidateGitGitHubTokenRequiresTokenRef(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "https://github.com/example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthGitHubToken}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for missing token ref, got nil") + } + if !strings.Contains(err.Error(), "token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestValidateGitGitHubTokenRejectsSSHURL(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "git@github.com:example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthGitHubToken, TokenEnv: "GH_TOKEN"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for github-token with ssh URL, got nil") + } + if !strings.Contains(err.Error(), "https") { + t.Fatalf("expected https error, got: %v", err) + } +} + +// ---- ssh-key: key_file vs key_env ---------------------------------------- + +func TestValidateGitSSHKeyEnvOK(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "git@github.com:example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthSSHKey, KeyEnv: "SSH_KEY"}, + }, + File: "test", + }} + if err := Validate(&cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateGitSSHKeyRequiresAKeyRef(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "git@github.com:example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthSSHKey}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for ssh-key with no key_file/key_env, got nil") + } + if !strings.Contains(err.Error(), "key_env") || !strings.Contains(err.Error(), "key_file") { + t.Fatalf("expected key_env/key_file error, got: %v", err) + } +} + +func TestValidateGitSSHKeyEnvAndFileExclusive(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "git@github.com:example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthSSHKey, KeyEnv: "SSH_KEY", KeyFile: "/etc/keys/id"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for key_env and key_file both set, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutually-exclusive error, got: %v", err) + } +} + +func TestValidateGitInlineKeyRejected(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceGit, + URL: "git@github.com:example/repo.git", + Branch: "main", + Auth: Auth{Method: AuthSSHKey, Key: "-----BEGIN OPENSSH PRIVATE KEY-----"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for inline key, got nil") + } + if !strings.Contains(err.Error(), "inline secrets") { + t.Fatalf("expected inline-secrets error, got: %v", err) + } +} + +// ---- http-zip auth matrix ------------------------------------------------- + +func TestValidateHTTPZipBearerRequiresTokenRef(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/a.zip", + Auth: Auth{Method: AuthBearer}, // neither token_env nor token_file + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for bearer with no token ref, got nil") + } +} + +func TestValidateHTTPZipBearerTokenEnvAndFileExclusive(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/a.zip", + Auth: Auth{Method: AuthBearer, TokenEnv: "TOK", TokenFile: "/tok"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for both token_env and token_file set, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestValidateHTTPZipBasicRequiresUsername(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/a.zip", + Auth: Auth{Method: AuthBasic, PasswordEnv: "MY_PASS"}, + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for missing username in basic auth, got nil") + } + if !strings.Contains(err.Error(), "username") { + t.Fatalf("expected username error, got: %v", err) + } +} + +func TestValidateHTTPZipHeaderRequiresName(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "https://example.com/a.zip", + Auth: Auth{Method: AuthHeader, ValueEnv: "MY_HDR"}, // no name + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for missing auth.name in header auth, got nil") + } + if !strings.Contains(err.Error(), "name") { + t.Fatalf("expected name error, got: %v", err) + } +} + +func TestValidateHTTPZipInsecureRequiresFlag(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "http://example.com/a.zip", // http:// without allow_insecure + }, + File: "test", + }} + err := Validate(&cfg) + if err == nil { + t.Fatal("expected error for http:// without allow_insecure, got nil") + } + if !strings.Contains(err.Error(), "allow_insecure") { + t.Fatalf("expected allow_insecure error, got: %v", err) + } +} + +func TestValidateHTTPZipInsecureWithFlagOK(t *testing.T) { + cfg := minValidConfig() + cfg.Sites = []Site{{ + Domain: "example.com", + Source: Source{ + Type: SourceHTTPZip, + URL: "http://example.com/a.zip", + AllowInsecure: true, + }, + File: "test", + }} + if err := Validate(&cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/nginxpilot/internal/deploy/deploy.go b/nginxpilot/internal/deploy/deploy.go index ce223127..1082f6eb 100644 --- a/nginxpilot/internal/deploy/deploy.go +++ b/nginxpilot/internal/deploy/deploy.go @@ -30,14 +30,13 @@ var DefaultExcludes = []string{".env*", ".htaccess", ".DS_Store"} // Deployer manages the sites/ tree under data_dir. type Deployer struct { - dataDir string - keepReleases int - log *slog.Logger + dataDir string + log *slog.Logger } // New returns a Deployer rooted at dataDir. -func New(dataDir string, keepReleases int, log *slog.Logger) *Deployer { - return &Deployer{dataDir: dataDir, keepReleases: keepReleases, log: log} +func New(dataDir string, log *slog.Logger) *Deployer { + return &Deployer{dataDir: dataDir, log: log} } // SiteDir returns sites/ under data_dir. @@ -50,6 +49,12 @@ func (d *Deployer) CurrentPath(domain string) string { return filepath.Join(d.SiteDir(domain), "current") } +// CurrentExists reports whether /current resolves to a real directory. +func (d *Deployer) CurrentExists(domain string) bool { + fi, err := os.Stat(d.CurrentPath(domain)) // follows the symlink + return err == nil && fi.IsDir() +} + // TmpDir returns the shared staging/download area under data_dir. func (d *Deployer) TmpDir() string { return filepath.Join(d.dataDir, "tmp") @@ -122,7 +127,8 @@ func CountRegularFiles(dir string) (int, error) { // Promote turns a fully staged directory into the live release: // normalize perms, fsync, rename into releases/, swap the symlink, prune. // ref should be a short content identifier (git SHA / hash prefix). -func (d *Deployer) Promote(domain, ref, stagingDir string) (string, error) { +// keep is the maximum number of releases to retain after the swap. +func (d *Deployer) Promote(domain, ref, stagingDir string, keep int) (string, error) { releasesDir := filepath.Join(d.SiteDir(domain), "releases") if err := os.MkdirAll(releasesDir, 0o750); err != nil { return "", err @@ -162,15 +168,15 @@ func (d *Deployer) Promote(domain, ref, stagingDir string) (string, error) { } // 4. Prune old releases. - if err := d.Prune(domain); err != nil { + if err := d.Prune(domain, keep); err != nil { d.log.Warn("prune failed", "domain", domain, "error", err) } return releasePath, nil } -// Prune deletes releases beyond keepReleases, oldest first, never deleting -// the target of `current`. -func (d *Deployer) Prune(domain string) error { +// Prune deletes releases beyond keep, oldest first, never deleting the target +// of `current`. keep is the maximum number of releases to retain. +func (d *Deployer) Prune(domain string, keep int) error { releasesDir := filepath.Join(d.SiteDir(domain), "releases") entries, err := os.ReadDir(releasesDir) if err != nil { @@ -187,7 +193,7 @@ func (d *Deployer) Prune(domain string) error { } } sort.Strings(names) // timestamp prefix => lexical == chronological - excess := len(names) - d.keepReleases + excess := len(names) - keep for _, name := range names { if excess <= 0 { break diff --git a/nginxpilot/internal/deploy/deploy_test.go b/nginxpilot/internal/deploy/deploy_test.go new file mode 100644 index 00000000..59f57145 --- /dev/null +++ b/nginxpilot/internal/deploy/deploy_test.go @@ -0,0 +1,421 @@ +package deploy + +import ( + "io" + "log/slog" + "os" + "path/filepath" + "testing" +) + +func TestCurrentExists(t *testing.T) { + dep := New(t.TempDir(), slog.Default()) + + domain := "example.com" + siteDir := dep.SiteDir(domain) + if err := os.MkdirAll(siteDir, 0o750); err != nil { + t.Fatal(err) + } + + releaseDir := filepath.Join(siteDir, "releases", "20240101T000000-abc") + if err := os.MkdirAll(releaseDir, 0o750); err != nil { + t.Fatal(err) + } + + currentLink := dep.CurrentPath(domain) + + tests := []struct { + name string + setup func() + want bool + }{ + { + name: "valid symlink pointing at existing release dir", + setup: func() { + _ = os.Remove(currentLink) + if err := os.Symlink(filepath.Join("releases", "20240101T000000-abc"), currentLink); err != nil { + t.Fatal(err) + } + }, + want: true, + }, + { + name: "dangling symlink whose target was deleted", + setup: func() { + _ = os.Remove(currentLink) + if err := os.Symlink(filepath.Join("releases", "20240101T000000-gone"), currentLink); err != nil { + t.Fatal(err) + } + }, + want: false, + }, + { + name: "current symlink absent entirely", + setup: func() { + _ = os.Remove(currentLink) + }, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + got := dep.CurrentExists(domain) + if got != tc.want { + t.Errorf("CurrentExists(%q) = %v, want %v", domain, got, tc.want) + } + }) + } +} + +// TestPrune verifies that Prune honors the per-call keep count: it deletes +// oldest releases first and never removes the one pointed to by `current`. +func TestPrune(t *testing.T) { + t.Parallel() + + dep := New(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))) + domain := "prune.example.com" + releasesDir := filepath.Join(dep.SiteDir(domain), "releases") + if err := os.MkdirAll(releasesDir, 0o750); err != nil { + t.Fatal(err) + } + + // Create five release directories in chronological order. + names := []string{ + "20240101T000000-r1", + "20240102T000000-r2", + "20240103T000000-r3", + "20240104T000000-r4", + "20240105T000000-r5", + } + for _, name := range names { + if err := os.MkdirAll(filepath.Join(releasesDir, name), 0o750); err != nil { + t.Fatal(err) + } + } + + // Point `current` at the newest release. + currentTarget := names[len(names)-1] + if err := os.Symlink(filepath.Join("releases", currentTarget), dep.CurrentPath(domain)); err != nil { + t.Fatal(err) + } + + // Prune to keep=2: should delete r1, r2, r3 (oldest three) and keep r4, r5. + if err := dep.Prune(domain, 2); err != nil { + t.Fatalf("Prune: %v", err) + } + + wantPresent := map[string]bool{names[3]: true, names[4]: true} + wantAbsent := map[string]bool{names[0]: true, names[1]: true, names[2]: true} + + entries, err := os.ReadDir(releasesDir) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, e := range entries { + if e.IsDir() { + got[e.Name()] = true + } + } + + for name := range wantPresent { + if !got[name] { + t.Errorf("Prune(keep=2): expected %q to be kept, but it was removed", name) + } + } + for name := range wantAbsent { + if got[name] { + t.Errorf("Prune(keep=2): expected %q to be pruned, but it remains", name) + } + } + + // The current symlink target must still be present. + if !got[currentTarget] { + t.Errorf("Prune removed the current release %q", currentTarget) + } +} + +// TestPromote verifies the full Promote flow: +// - staging dir is moved into releases/ (not left in place) +// - `current` symlink points at the new release +// - content is accessible via `current` +func TestPromote(t *testing.T) { + t.Parallel() + + dep := New(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))) + domain := "promote.example.com" + + // Build a staging directory inside dep.dataDir so os.Rename works (same fs). + staging, err := dep.NewStaging(domain) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staging, "index.html"), []byte(""), 0o640); err != nil { + t.Fatal(err) + } + + releasePath, err := dep.Promote(domain, "abc123", staging, 5) + if err != nil { + t.Fatalf("Promote: %v", err) + } + + // The returned release path must exist. + if _, err := os.Stat(releasePath); err != nil { + t.Errorf("release path %q does not exist: %v", releasePath, err) + } + + // `current` must resolve to a real directory. + if !dep.CurrentExists(domain) { + t.Error("CurrentExists returned false after successful Promote") + } + + // The staged file must be accessible via `current`. + currentIdx := filepath.Join(dep.CurrentPath(domain), "index.html") + if _, err := os.Stat(currentIdx); err != nil { + t.Errorf("index.html not reachable through current: %v", err) + } + + // The original staging directory must have been moved (not copied). + if _, err := os.Stat(staging); !os.IsNotExist(err) { + t.Error("staging dir still exists after Promote — it should have been moved") + } +} + +// TestPromotePrunesOldReleases verifies that Promote calls Prune so only +// `keep` releases survive after the swap. +func TestPromotePrunesOldReleases(t *testing.T) { + t.Parallel() + + dep := New(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))) + domain := "prune-via-promote.example.com" + + // Pre-create three releases so Promote's Prune(keep=2) has something to do. + releasesDir := filepath.Join(dep.SiteDir(domain), "releases") + if err := os.MkdirAll(releasesDir, 0o750); err != nil { + t.Fatal(err) + } + for _, name := range []string{"20240101T000000-r1", "20240102T000000-r2", "20240103T000000-r3"} { + if err := os.MkdirAll(filepath.Join(releasesDir, name), 0o750); err != nil { + t.Fatal(err) + } + } + + staging, err := dep.NewStaging(domain) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staging, "f.txt"), []byte("hi"), 0o640); err != nil { + t.Fatal(err) + } + if _, err := dep.Promote(domain, "newref", staging, 2); err != nil { + t.Fatalf("Promote: %v", err) + } + + entries, err := os.ReadDir(releasesDir) + if err != nil { + t.Fatal(err) + } + var dirs []string + for _, e := range entries { + if e.IsDir() { + dirs = append(dirs, e.Name()) + } + } + if len(dirs) > 2 { + t.Errorf("expected at most 2 releases after Promote(keep=2), got %d: %v", len(dirs), dirs) + } +} + +// TestApplyExcludes verifies: +// - .git* is always stripped +// - DefaultExcludes (.env*, .htaccess, .DS_Store) are stripped +// - site-specific patterns are stripped +// - regular files survive +func TestApplyExcludes(t *testing.T) { + t.Parallel() + + staging := t.TempDir() + + // Create files that should be removed. + gitDir := filepath.Join(staging, ".git") + if err := os.MkdirAll(gitDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main"), 0o640); err != nil { + t.Fatal(err) + } + for _, name := range []string{".gitignore", ".env", ".env.local", ".htaccess", ".DS_Store"} { + if err := os.WriteFile(filepath.Join(staging, name), []byte("secret"), 0o640); err != nil { + t.Fatal(err) + } + } + // Site-specific pattern. + if err := os.WriteFile(filepath.Join(staging, "secrets.txt"), []byte("shh"), 0o640); err != nil { + t.Fatal(err) + } + + // Create files that must survive. + if err := os.WriteFile(filepath.Join(staging, "index.html"), []byte(""), 0o640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staging, "main.css"), []byte("body{}"), 0o640); err != nil { + t.Fatal(err) + } + + removed, err := ApplyExcludes(staging, []string{"secrets.txt"}) + if err != nil { + t.Fatalf("ApplyExcludes: %v", err) + } + + removedSet := make(map[string]bool, len(removed)) + for _, r := range removed { + removedSet[r] = true + } + + // These must be gone. + mustBeRemoved := []string{".git", ".gitignore", ".env", ".env.local", ".htaccess", ".DS_Store", "secrets.txt"} + for _, name := range mustBeRemoved { + if _, err := os.Stat(filepath.Join(staging, name)); !os.IsNotExist(err) { + t.Errorf("%q should have been excluded but still exists", name) + } + } + + // These must survive. + for _, name := range []string{"index.html", "main.css"} { + if _, err := os.Stat(filepath.Join(staging, name)); err != nil { + t.Errorf("%q should survive ApplyExcludes but got: %v", name, err) + } + } + + // The removed list must include the git and env items. + for _, name := range []string{".git", ".gitignore", ".env"} { + if !removedSet[name] { + t.Errorf("expected %q in removed list, got: %v", name, removed) + } + } +} + +// TestApplyExcludesGitAlwaysStripped verifies that .git* items are removed +// even when no site-level exclusions are supplied. +func TestApplyExcludesGitAlwaysStripped(t *testing.T) { + t.Parallel() + + staging := t.TempDir() + for _, name := range []string{".git", ".gitmodules", ".github"} { + p := filepath.Join(staging, name) + if err := os.MkdirAll(p, 0o750); err != nil { + t.Fatal(err) + } + } + // One regular file must survive. + if err := os.WriteFile(filepath.Join(staging, "index.html"), []byte("ok"), 0o640); err != nil { + t.Fatal(err) + } + + if _, err := ApplyExcludes(staging, nil); err != nil { + t.Fatalf("ApplyExcludes: %v", err) + } + + for _, name := range []string{".git", ".gitmodules", ".github"} { + if _, err := os.Stat(filepath.Join(staging, name)); !os.IsNotExist(err) { + t.Errorf("%q must always be stripped but still exists", name) + } + } + if _, err := os.Stat(filepath.Join(staging, "index.html")); err != nil { + t.Error("index.html must survive ApplyExcludes") + } +} + +// TestPruneNeverDeletesCurrent verifies that Prune with keep=0 still does not +// delete the release that `current` points to. +func TestPruneNeverDeletesCurrent(t *testing.T) { + t.Parallel() + + dep := New(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))) + domain := "guard.example.com" + + releasesDir := filepath.Join(dep.SiteDir(domain), "releases") + if err := os.MkdirAll(releasesDir, 0o750); err != nil { + t.Fatal(err) + } + relName := "20240101T000000-only" + if err := os.MkdirAll(filepath.Join(releasesDir, relName), 0o750); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("releases", relName), dep.CurrentPath(domain)); err != nil { + t.Fatal(err) + } + + // Prune with keep=0 — current must survive. + if err := dep.Prune(domain, 0); err != nil { + t.Fatalf("Prune: %v", err) + } + + if !dep.CurrentExists(domain) { + t.Error("Prune deleted the live release when keep=0") + } +} + +// TestSanitizeRef verifies edge cases for the internal sanitizeRef helper. +func TestSanitizeRef(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"abc123", "abc123"}, + {"abc/def", "abc-def"}, + {"refs/heads/main", "refs-heads-m"}, // truncated at 12 chars + {"", "unknown"}, + {"!!!", "---"}, + {"a.b-c", "a.b-c"}, + } + for _, tc := range tests { + got := sanitizeRef(tc.input) + if got != tc.want { + t.Errorf("sanitizeRef(%q) = %q, want %q", tc.input, got, tc.want) + } + if len(got) > 12 { + t.Errorf("sanitizeRef(%q) = %q: longer than 12 chars", tc.input, got) + } + if got == "" { + t.Errorf("sanitizeRef(%q) must never return empty string", tc.input) + } + // Result must only contain allowed chars. + for _, r := range got { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '.') { + t.Errorf("sanitizeRef(%q) = %q: contains disallowed char %q", tc.input, got, r) + } + } + } +} + +// TestApplyExcludesAllowedPatternDoesNotMatchSubstring ensures that a +// pattern like "secrets.txt" does not accidentally match "secrets.txt.bak" +// or "mysecrets.txt" (filepath.Match is not a substring search). +func TestApplyExcludesPatternIsExact(t *testing.T) { + t.Parallel() + + staging := t.TempDir() + for _, name := range []string{"secrets.txt", "secrets.txt.bak", "mysecrets.txt"} { + if err := os.WriteFile(filepath.Join(staging, name), []byte("x"), 0o640); err != nil { + t.Fatal(err) + } + } + + _, err := ApplyExcludes(staging, []string{"secrets.txt"}) + if err != nil { + t.Fatalf("ApplyExcludes: %v", err) + } + + // Only the exact match must be removed. + if _, err := os.Stat(filepath.Join(staging, "secrets.txt")); !os.IsNotExist(err) { + t.Error("secrets.txt should have been removed") + } + for _, name := range []string{"secrets.txt.bak", "mysecrets.txt"} { + if _, err := os.Stat(filepath.Join(staging, name)); err != nil { + t.Errorf("%q should not have been removed: %v", name, err) + } + } +} diff --git a/nginxpilot/internal/manager/manager.go b/nginxpilot/internal/manager/manager.go index f0c8c9f5..ad074d26 100644 --- a/nginxpilot/internal/manager/manager.go +++ b/nginxpilot/internal/manager/manager.go @@ -7,12 +7,15 @@ import ( "context" "log/slog" "math/rand/v2" + "os" + "path/filepath" "reflect" "sync" "time" "github.com/kalevski/toolcase/nginxpilot/internal/config" "github.com/kalevski/toolcase/nginxpilot/internal/deploy" + gitsource "github.com/kalevski/toolcase/nginxpilot/internal/source/git" "github.com/kalevski/toolcase/nginxpilot/internal/state" ) @@ -30,6 +33,7 @@ type Manager struct { loops map[string]*siteLoop wg sync.WaitGroup ctx context.Context + syncFn func(ctx context.Context, site config.Site, defaults config.Defaults, dataDir string, store *state.Store, dep *deploy.Deployer, log *slog.Logger) (*state.SiteState, error) } type siteLoop struct { @@ -65,8 +69,9 @@ func New(cfg *config.Config, store *state.Store, log *slog.Logger) *Manager { log: log, store: store, cfg: cfg, - deployer: deploy.New(cfg.DataDir, cfg.Defaults.KeepReleases, log), + deployer: deploy.New(cfg.DataDir, log), loops: map[string]*siteLoop{}, + syncFn: SyncSite, } } @@ -76,6 +81,7 @@ func New(cfg *config.Config, store *state.Store, log *slog.Logger) *Manager { func (m *Manager) Run(ctx context.Context) { m.mu.Lock() m.ctx = ctx + m.reconcileState() for i := range m.cfg.Sites { m.startLoop(m.cfg.Sites[i]) } @@ -88,6 +94,25 @@ func (m *Manager) Run(ctx context.Context) { m.wg.Wait() } +// reconcileState clears DeployedRef and HTTP validators for any site whose +// state records a deploy but whose current/ symlink resolves to nothing. +// Called once at startup (with m.mu held) before loops begin, so a daemon +// restarted after a manual rm of current/ self-heals on the first tick. +func (m *Manager) reconcileState() { + for _, site := range m.cfg.Sites { + if !m.deployer.CurrentExists(site.Domain) { + st, err := m.store.Load(site.Domain) + if err != nil || st.DeployedRef == "" { + continue + } + m.log.Warn("state records a deploy but current is missing; clearing ref so next sync redeploys", + "domain", site.Domain) + st.DeployedRef, st.ETag, st.LastModified, st.ContentHash = "", "", "", "" + _ = m.store.Save(st) + } + } +} + // startLoop must be called with m.mu held and m.ctx set. func (m *Manager) startLoop(site config.Site) { loopCtx, cancel := context.WithCancel(m.ctx) @@ -148,12 +173,13 @@ func (m *Manager) syncOnce(ctx context.Context, sl *siteLoop) (streak int) { m.mu.Lock() dep := m.deployer dataDir := m.cfg.DataDir + defaults := m.cfg.Defaults m.mu.Unlock() syncCtx, cancel := context.WithTimeout(ctx, syncTimeout) defer cancel() - st, err := SyncSite(syncCtx, sl.site, dataDir, m.store, dep, m.log) + st, err := m.syncFn(syncCtx, sl.site, defaults, dataDir, m.store, dep, m.log) if err != nil { m.log.Error("sync failed", "domain", sl.site.Domain, "error", err, "failure_streak", st.FailureStreak) @@ -269,7 +295,7 @@ func (m *Manager) Reload(newCfg *config.Config) { } m.cfg = newCfg - m.deployer = deploy.New(newCfg.DataDir, newCfg.Defaults.KeepReleases, m.log) + m.deployer = deploy.New(newCfg.DataDir, m.log) // Removed sites: stop the loop; content stays on disk (orphan). for domain, sl := range m.loops { @@ -293,11 +319,21 @@ func (m *Manager) Reload(newCfg *config.Config) { case !sameSite(old, site) || sl.interval != site.Interval(newCfg.Defaults): // Changed → restart loop. A source identity change is caught by // the fingerprint check inside the sync and forces a full resync. + // We wait for the old loop's done channel before starting the new + // one so two syncs never overlap on the same domain (BUG-3/BUG-6). m.log.Info("site changed, restarting loop", "domain", domain) sl.cancel() delete(m.loops, domain) - m.startLoop(site) - m.loops[domain].kick <- struct{}{} + go func(old *siteLoop, ns config.Site) { + <-old.done + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.loops[ns.Domain]; exists { + return // re-added by a concurrent Reload + } + m.startLoop(ns) + m.loops[ns.Domain].kick <- struct{}{} + }(sl, site) } } @@ -311,7 +347,8 @@ func sameSite(a, b config.Site) bool { } // warnOrphans logs site directories on disk that no configured domain owns -// (spec §6: warning while orphans exist; --prune-orphans deletes). +// (spec §6: warning while orphans exist; --prune-orphans deletes) and +// immediately removes stale git bare-repo caches (task 592, IMP-5/613). func (m *Manager) warnOrphans() { m.mu.Lock() dep := m.deployer @@ -330,6 +367,49 @@ func (m *Manager) warnOrphans() { m.log.Warn("orphaned site content on disk (not in config); use --prune-orphans to delete", "domain", domain) } + + m.pruneGitCaches() +} + +// pruneGitCaches removes git bare-repo cache dirs under cache/git/ that no +// configured site references, preventing unbounded growth when a site's URL +// or branch changes. Git caches are fully disposable — deleted caches are +// recloned on the next sync. Mirrors warnOrphans/PruneOrphans for sites +// (task 592, cross-ref task 613). +func (m *Manager) pruneGitCaches() { + m.mu.Lock() + dataDir := m.cfg.DataDir + sites := m.cfg.Sites + m.mu.Unlock() + + live := map[string]bool{} + for _, site := range sites { + if site.Source.Type == config.SourceGit { + live[gitsource.CacheDir(site.Domain, dataDir, site.Source.URL, site.Source.Branch)] = true + } + } + + cacheBase := filepath.Join(dataDir, "cache", "git") + entries, err := os.ReadDir(cacheBase) + if os.IsNotExist(err) { + return + } + if err != nil { + m.log.Warn("git cache GC scan failed", "error", err) + return + } + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(cacheBase, e.Name()) + if !live[dir] { + m.log.Info("removing stale git cache", "dir", e.Name()) + if err := os.RemoveAll(dir); err != nil { + m.log.Warn("git cache GC remove failed", "dir", e.Name(), "error", err) + } + } + } } // PruneOrphans deletes orphaned site content and state (--prune-orphans). diff --git a/nginxpilot/internal/manager/manager_test.go b/nginxpilot/internal/manager/manager_test.go new file mode 100644 index 00000000..7e8f2e91 --- /dev/null +++ b/nginxpilot/internal/manager/manager_test.go @@ -0,0 +1,305 @@ +package manager + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/kalevski/toolcase/nginxpilot/internal/config" + "github.com/kalevski/toolcase/nginxpilot/internal/deploy" + gitsource "github.com/kalevski/toolcase/nginxpilot/internal/source/git" + "github.com/kalevski/toolcase/nginxpilot/internal/state" +) + +// TestReloadDrainsOldLoop verifies that after a site-changed Reload the new +// loop's first sync does not begin until the old loop's goroutine has exited +// (done channel closed). This prevents concurrent state-file and git-cache +// writes for the same domain (BUG-3 / BUG-6). +func TestReloadDrainsOldLoop(t *testing.T) { + t.Parallel() + + var ( + // syncStarted receives the call number (1-based) each time syncFn starts. + syncStarted = make(chan int, 10) + // syncRelease is closed to unblock the first (blocking) sync. + syncRelease = make(chan struct{}) + ) + var callCount int32 + + dir := t.TempDir() + store, err := state.NewStore(dir) + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + // Use a short interval so jitter is negligible (< 1 ms). + interval := config.Duration(10 * time.Millisecond) + + makeSite := func(url string) config.Site { + return config.Site{ + Domain: "example.com", + Source: config.Source{Type: "git", URL: url, Interval: interval}, + } + } + + cfg := &config.Config{ + DataDir: dir, + Sites: []config.Site{makeSite("fake://v1")}, + } + + m := &Manager{ + log: logger, + store: store, + cfg: cfg, + deployer: deploy.New(dir, logger), + loops: map[string]*siteLoop{}, + syncFn: func(ctx context.Context, site config.Site, defaults config.Defaults, dataDir string, st *state.Store, dep *deploy.Deployer, log *slog.Logger) (*state.SiteState, error) { + n := int(atomic.AddInt32(&callCount, 1)) + syncStarted <- n + if n == 1 { + // Simulate a non-cancellable long sync: block until released + // even if the context is cancelled. + select { + case <-syncRelease: + case <-ctx.Done(): + <-syncRelease + } + } + return &state.SiteState{Domain: site.Domain}, nil + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go m.Run(ctx) + + // Wait for the first sync to start. + select { + case n := <-syncStarted: + if n != 1 { + t.Fatalf("expected call 1 first, got call %d", n) + } + case <-time.After(5 * time.Second): + t.Fatal("timeout: first sync never started") + } + + // Reload with a changed site URL — triggers the changed-site branch. + newCfg := &config.Config{ + DataDir: dir, + Sites: []config.Site{makeSite("fake://v2")}, + } + m.Reload(newCfg) + + // The new loop must not start its sync while the old one is still running. + select { + case n := <-syncStarted: + t.Fatalf("new loop (call %d) started before old loop exited — drain missing", n) + case <-time.After(100 * time.Millisecond): + // Correct: new sync has not started while old sync is blocked. + } + + // Unblock the old sync so the old goroutine can exit. + close(syncRelease) + + // Now the new loop must start its first sync. + select { + case n := <-syncStarted: + if n != 2 { + t.Fatalf("expected call 2 after drain, got call %d", n) + } + case <-time.After(5 * time.Second): + t.Fatal("timeout: new loop's sync never started after old loop exited") + } +} + +// TestReconcileStateClearsStaleRef verifies that reconcileState clears +// DeployedRef (and related HTTP validators) when state says a deploy exists +// but the current/ symlink is absent from disk. +func TestReconcileStateClearsStaleRef(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + store, err := state.NewStore(dir) + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + domain := "example.com" + + // Seed state with a recorded deploy and HTTP validators, but leave the + // current/ symlink absent (no releases directory either). + seed := &state.SiteState{ + Domain: domain, + DeployedRef: "abc123", + ETag: `"etag-val"`, + LastModified: "Thu, 01 Jan 2026 00:00:00 GMT", + ContentHash: "deadbeef", + } + if err := store.Save(seed); err != nil { + t.Fatal(err) + } + + // Create the site directory WITHOUT a current symlink so CurrentExists → false. + siteDir := filepath.Join(dir, "sites", domain) + if err := os.MkdirAll(siteDir, 0o750); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + DataDir: dir, + Sites: []config.Site{{Domain: domain, Source: config.Source{Type: "git", URL: "fake://x"}}}, + } + m := &Manager{ + log: logger, + store: store, + cfg: cfg, + deployer: deploy.New(dir, logger), + loops: map[string]*siteLoop{}, + syncFn: SyncSite, + } + + m.reconcileState() + + got, err := store.Load(domain) + if err != nil { + t.Fatal(err) + } + if got.DeployedRef != "" { + t.Errorf("DeployedRef not cleared: got %q", got.DeployedRef) + } + if got.ETag != "" { + t.Errorf("ETag not cleared: got %q", got.ETag) + } + if got.LastModified != "" { + t.Errorf("LastModified not cleared: got %q", got.LastModified) + } + if got.ContentHash != "" { + t.Errorf("ContentHash not cleared: got %q", got.ContentHash) + } +} + +// TestPruneGitCaches verifies that pruneGitCaches removes cache dirs not +// referenced by any configured git site and leaves live ones untouched. +func TestPruneGitCaches(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + const domain = "example.com" + const url = "https://github.com/example/repo.git" + const branch = "main" + + // Create the cache/git/ directory and populate it. + cacheBase := filepath.Join(dir, "cache", "git") + if err := os.MkdirAll(cacheBase, 0o750); err != nil { + t.Fatal(err) + } + + // Live dir: what CacheDir produces for the configured site. + liveDir := gitsource.CacheDir(domain, dir, url, branch) + if err := os.MkdirAll(liveDir, 0o750); err != nil { + t.Fatal(err) + } + + // Stale dir: a leftover from a previously removed or identity-changed site. + staleDir := filepath.Join(cacheBase, "deadbeefdeadbeef") + if err := os.MkdirAll(staleDir, 0o750); err != nil { + t.Fatal(err) + } + + store, err := state.NewStore(dir) + if err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + DataDir: dir, + Sites: []config.Site{ + { + Domain: domain, + Source: config.Source{Type: config.SourceGit, URL: url, Branch: branch}, + }, + }, + } + m := &Manager{ + log: logger, + store: store, + cfg: cfg, + deployer: deploy.New(dir, logger), + loops: map[string]*siteLoop{}, + syncFn: SyncSite, + } + + m.pruneGitCaches() + + if _, err := os.Stat(liveDir); os.IsNotExist(err) { + t.Error("live cache dir was incorrectly removed") + } + if _, err := os.Stat(staleDir); !os.IsNotExist(err) { + t.Error("stale cache dir was not removed") + } +} + +// TestReconcileStateSkipsWhenCurrentExists verifies that reconcileState does +// not touch a site whose current/ symlink resolves to a real directory. +func TestReconcileStateSkipsWhenCurrentExists(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + store, err := state.NewStore(dir) + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + domain := "live.example.com" + + seed := &state.SiteState{ + Domain: domain, + DeployedRef: "live-sha", + } + if err := store.Save(seed); err != nil { + t.Fatal(err) + } + + // Build a valid current/ → releases/... structure. + dep := deploy.New(dir, logger) + releaseDir := filepath.Join(dep.SiteDir(domain), "releases", "20260101T000000-live-sha") + if err := os.MkdirAll(releaseDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("releases", "20260101T000000-live-sha"), dep.CurrentPath(domain)); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + DataDir: dir, + Sites: []config.Site{{Domain: domain, Source: config.Source{Type: "git", URL: "fake://x"}}}, + } + m := &Manager{ + log: logger, + store: store, + cfg: cfg, + deployer: dep, + loops: map[string]*siteLoop{}, + syncFn: SyncSite, + } + + m.reconcileState() + + got, err := store.Load(domain) + if err != nil { + t.Fatal(err) + } + if got.DeployedRef != "live-sha" { + t.Errorf("DeployedRef must not be cleared when current/ exists; got %q", got.DeployedRef) + } +} diff --git a/nginxpilot/internal/manager/sync.go b/nginxpilot/internal/manager/sync.go index d4d5dfe4..0e1b5efd 100644 --- a/nginxpilot/internal/manager/sync.go +++ b/nginxpilot/internal/manager/sync.go @@ -32,7 +32,7 @@ func (sl *siteLoop) setSyncing(v bool) { func buildSource(site config.Site, dataDir string, log *slog.Logger) (source.Source, error) { switch site.Source.Type { case config.SourceGit: - return gitsource.New(site.Source, dataDir, log), nil + return gitsource.New(site.Domain, site.Source, dataDir, log), nil case config.SourceHTTPZip: return httpzip.New(site.Domain, site.Source, dataDir, log), nil default: @@ -50,7 +50,7 @@ func buildSource(site config.Site, dataDir string, log *slog.Logger) (source.Sou // on failure it carries the recorded error and bumped failure streak // (spec §4.5: any failure before the symlink rename leaves `current` // untouched; the last good version keeps serving). -func SyncSite(ctx context.Context, site config.Site, dataDir string, store *state.Store, dep *deploy.Deployer, log *slog.Logger) (*state.SiteState, error) { +func SyncSite(ctx context.Context, site config.Site, defaults config.Defaults, dataDir string, store *state.Store, dep *deploy.Deployer, log *slog.Logger) (*state.SiteState, error) { st, err := store.Load(site.Domain) if err != nil { return &state.SiteState{Domain: site.Domain}, err @@ -65,7 +65,8 @@ func SyncSite(ctx context.Context, site config.Site, dataDir string, store *stat } st.SourceFingerprint = fp - err = doSync(ctx, site, dataDir, st, dep, log) + keep := site.KeepReleases(defaults) + err = doSync(ctx, site, dataDir, keep, st, dep, log) if err != nil { st.FailureStreak++ st.LastError = err.Error() @@ -86,7 +87,7 @@ func SyncSite(ctx context.Context, site config.Site, dataDir string, store *stat } // doSync mutates st on success (deployed ref, validators, timestamps). -func doSync(ctx context.Context, site config.Site, dataDir string, st *state.SiteState, dep *deploy.Deployer, log *slog.Logger) error { +func doSync(ctx context.Context, site config.Site, dataDir string, keep int, st *state.SiteState, dep *deploy.Deployer, log *slog.Logger) error { src, err := buildSource(site, dataDir, log) if err != nil { return err @@ -108,6 +109,16 @@ func doSync(ctx context.Context, site config.Site, dataDir string, st *state.Sit return err } + if !res.Changed && !dep.CurrentExists(site.Domain) { + log.Warn("deployed ref recorded but current release missing; forcing resync", "domain", site.Domain) + st.DeployedRef, st.ETag, st.LastModified, st.ContentHash = "", "", "", "" + // re-run the source with cleared validators + res, err = src.Sync(ctx, st, staging) + if err != nil { + return err + } + } + if !res.Changed { // Cheap no-op (the common case) — still refresh validators so the // next conditional GET uses the freshest ones. @@ -147,7 +158,7 @@ func doSync(ctx context.Context, site config.Site, dataDir string, st *state.Sit } } - releasePath, err := dep.Promote(site.Domain, res.Ref, staging) + releasePath, err := dep.Promote(site.Domain, res.Ref, staging, keep) if err != nil { return fmt.Errorf("deploy: %w", err) } diff --git a/nginxpilot/internal/source/git/git.go b/nginxpilot/internal/source/git/git.go index bef1b986..a96bbdd9 100644 --- a/nginxpilot/internal/source/git/git.go +++ b/nginxpilot/internal/source/git/git.go @@ -26,21 +26,30 @@ import ( // Syncer syncs one git-backed site. type Syncer struct { + domain string url string branch string subdir string auth config.Auth + limits config.Limits dataDir string log *slog.Logger + + // keyPath is the on-disk ssh private key path for the current sync, + // resolved by materializeKey (key_file path as-is, or a temp file + // written from key_env). Empty outside an in-flight Sync. + keyPath string } // New builds a Syncer from a validated site source. -func New(src config.Source, dataDir string, log *slog.Logger) *Syncer { +func New(domain string, src config.Source, dataDir string, log *slog.Logger) *Syncer { return &Syncer{ + domain: domain, url: src.URL, branch: src.Branch, subdir: strings.Trim(path.Clean(src.Subdir), "/"), auth: src.Auth, + limits: src.Limits.Effective(), dataDir: dataDir, log: log, } @@ -49,10 +58,18 @@ func New(src config.Source, dataDir string, log *slog.Logger) *Syncer { // Type implements source.Source. func (s *Syncer) Type() string { return config.SourceGit } -// cacheDir is the shared bare fetch target — never served, fully disposable. +// CacheDir returns the bare-repo cache path for a site identified by domain, +// url, and branch. Identical inputs always return the same path; any field +// difference produces a distinct path. Exported so the manager can build the +// live set for garbage collection without constructing a Syncer. +func CacheDir(domain, dataDir, url, branch string) string { + sum := sha256.Sum256([]byte(domain + "\x00" + url + "\x00" + branch)) + return filepath.Join(dataDir, "cache", "git", hex.EncodeToString(sum[:8])) +} + +// cacheDir is the bare fetch target for this Syncer — never served, fully disposable. func (s *Syncer) cacheDir() string { - sum := sha256.Sum256([]byte(s.url + "\x00" + s.branch)) - return filepath.Join(s.dataDir, "cache", "git", hex.EncodeToString(sum[:8])) + return CacheDir(s.domain, s.dataDir, s.url, s.branch) } // Sync implements source.Source. @@ -61,6 +78,12 @@ func (s *Syncer) Sync(ctx context.Context, st *state.SiteState, stagingDir strin return nil, fmt.Errorf("git binary not found in PATH") } + cleanupKey, err := s.materializeKey() + if err != nil { + return nil, err + } + defer cleanupKey() + cache := s.cacheDir() if err := s.ensureCache(ctx, cache); err != nil { return nil, err @@ -157,12 +180,20 @@ func (s *Syncer) extract(ctx context.Context, cache, sha, stagingDir string) err // untar extracts a tar stream into dest, stripping the subdir prefix when // configured. Paths are validated against traversal; symlinks and other // special entries are skipped with a warning (an escaping symlink would be -// a read-anything primitive through nginx). +// a read-anything primitive through nginx). Entry count and total written +// bytes are bounded by s.limits (mirrors httpzip extraction guards). func (s *Syncer) untar(r io.Reader, dest string) error { + maxEntries := s.limits.MaxEntries + maxUncompressed := int64(s.limits.MaxUncompressedSize) + prefix := "" if s.subdir != "" && s.subdir != "." { prefix = s.subdir + "/" } + + var entries int + var written int64 + tr := tar.NewReader(r) for { hdr, err := tr.Next() @@ -172,6 +203,12 @@ func (s *Syncer) untar(r io.Reader, dest string) error { if err != nil { return err } + + entries++ + if entries > maxEntries { + return fmt.Errorf("limit exceeded: max_entries (%d)", maxEntries) + } + name := path.Clean(strings.TrimPrefix(hdr.Name, "./")) if name == "." { continue @@ -203,7 +240,10 @@ func (s *Syncer) untar(r io.Reader, dest string) error { if err != nil { return err } - _, copyErr := io.Copy(f, tr) + remaining := maxUncompressed - written + // +1 so that reading exactly budget+1 bytes signals overrun. + n, copyErr := io.Copy(f, io.LimitReader(tr, remaining+1)) + written += n closeErr := f.Close() if copyErr != nil { return copyErr @@ -211,6 +251,9 @@ func (s *Syncer) untar(r io.Reader, dest string) error { if closeErr != nil { return closeErr } + if written > maxUncompressed { + return fmt.Errorf("limit exceeded: max_uncompressed_size (%s)", s.limits.MaxUncompressedSize) + } case tar.TypeSymlink, tar.TypeLink: s.log.Warn("skipping link entry in git tree", "entry", hdr.Name) case tar.TypeXGlobalHeader, tar.TypeXHeader: @@ -261,21 +304,32 @@ func (s *Syncer) gitEnv() ([]string, error) { } else { kh = filepath.Join(s.dataDir, "known_hosts") } + keyPath := s.keyPath + if keyPath == "" { + keyPath = s.auth.KeyFile // materializeKey not run (e.g. unit test) + } sshCmd := fmt.Sprintf( "ssh -F /dev/null -i %s -o IdentitiesOnly=yes -o UserKnownHostsFile=%s -o StrictHostKeyChecking=%s -o BatchMode=yes", - shellQuote(s.auth.KeyFile), shellQuote(kh), strict) + shellQuote(keyPath), shellQuote(kh), strict) env = append(env, "GIT_SSH_COMMAND="+sshCmd) case config.AuthHTTPSToken: token, err := config.ResolveSecret(s.auth.TokenEnv, s.auth.TokenFile) if err != nil { return nil, fmt.Errorf("resolve git token: %w", err) } - basic := base64.StdEncoding.EncodeToString([]byte(s.auth.Username + ":" + token)) - env = append(env, - "GIT_CONFIG_COUNT=1", - "GIT_CONFIG_KEY_0=http."+s.url+".extraHeader", - "GIT_CONFIG_VALUE_0=Authorization: Basic "+basic, - ) + env = append(env, basicAuthEnv(s.url, s.auth.Username, token)...) + case config.AuthGitHubToken: + // Token-only GitHub auth: the access token is the basic-auth + // password under the fixed "x-access-token" username — the + // convention GitHub uses for OAuth / gh-CLI / app tokens, and + // which PATs also accept. The token is injected as an + // Authorization header via GIT_CONFIG_* so it never lands in + // argv or on disk (matches https-token, spec §4.1). + token, err := config.ResolveSecret(s.auth.TokenEnv, s.auth.TokenFile) + if err != nil { + return nil, fmt.Errorf("resolve git token: %w", err) + } + env = append(env, basicAuthEnv(s.url, "x-access-token", token)...) default: // none: still pin a sane GIT_SSH_COMMAND so ssh URLs can't fall // back to interactive prompts (validation forbids none+ssh anyway). @@ -287,3 +341,71 @@ func (s *Syncer) gitEnv() ([]string, error) { func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + +// materializeKey resolves the ssh private key (ssh-key auth) to a path ssh +// can read, setting s.keyPath and returning a cleanup func. +// +// - key_file: used in place; cleanup is a no-op. +// - key_env: the key material is written to a 0600 temp file owned by the +// daemon under data_dir/tmp, and cleanup removes it. This lets a key be +// supplied entirely through config + an env var (e.g. docker run -e +// SSH_KEY="$(cat id_ed25519)"), with no host-side staging or chown — the +// friction key_file hits in a container where the host-uid key is +// unreadable by the unprivileged daemon. +// +// For non-ssh-key auth it is a no-op. +func (s *Syncer) materializeKey() (func(), error) { + noop := func() {} + if s.auth.MethodOrNone() != config.AuthSSHKey { + return noop, nil + } + if s.auth.KeyFile != "" { + s.keyPath = s.auth.KeyFile + return noop, nil + } + + key, err := config.ResolveSecret(s.auth.KeyEnv, "") + if err != nil { + return nil, fmt.Errorf("resolve ssh key: %w", err) + } + dir := filepath.Join(s.dataDir, "tmp") + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("ssh key tmp dir: %w", err) + } + f, err := os.CreateTemp(dir, "sshkey-*") + if err != nil { + return nil, fmt.Errorf("create ssh key file: %w", err) + } + path := f.Name() + cleanup := func() { _ = os.Remove(path) } + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + cleanup() + return nil, fmt.Errorf("chmod ssh key file: %w", err) + } + // ResolveSecret trims surrounding whitespace; OpenSSH needs the trailing + // newline back or it rejects the key as malformed. + if _, err := f.WriteString(key + "\n"); err != nil { + _ = f.Close() + cleanup() + return nil, fmt.Errorf("write ssh key file: %w", err) + } + if err := f.Close(); err != nil { + cleanup() + return nil, fmt.Errorf("close ssh key file: %w", err) + } + s.keyPath = path + return cleanup, nil +} + +// basicAuthEnv builds the GIT_CONFIG_* entries that attach an +// `Authorization: Basic` header to requests for url, so the credential +// never appears in argv or on disk (spec §4.1). +func basicAuthEnv(url, user, token string) []string { + basic := base64.StdEncoding.EncodeToString([]byte(user + ":" + token)) + return []string{ + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=http." + url + ".extraHeader", + "GIT_CONFIG_VALUE_0=Authorization: Basic " + basic, + } +} diff --git a/nginxpilot/internal/source/git/git_test.go b/nginxpilot/internal/source/git/git_test.go new file mode 100644 index 00000000..e8885701 --- /dev/null +++ b/nginxpilot/internal/source/git/git_test.go @@ -0,0 +1,402 @@ +package git + +import ( + "archive/tar" + "bytes" + "encoding/base64" + "io" + "log/slog" + "os" + "strings" + "testing" + + "github.com/kalevski/toolcase/nginxpilot/internal/config" +) + +// TestGitEnvGitHubToken verifies that github-token auth injects the access +// token as an `Authorization: Basic` header under the fixed +// "x-access-token" username — via GIT_CONFIG_*, never in argv. +func TestGitEnvGitHubToken(t *testing.T) { + t.Setenv("GH_TOKEN", "ghs_exampletoken") + s := &Syncer{ + url: "https://github.com/example/repo.git", + auth: config.Auth{Method: config.AuthGitHubToken, TokenEnv: "GH_TOKEN"}, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + env, err := s.gitEnv() + if err != nil { + t.Fatalf("gitEnv: %v", err) + } + + want := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:ghs_exampletoken")) + var gotHeader, gotKey bool + for _, e := range env { + if e == "GIT_CONFIG_VALUE_0="+want { + gotHeader = true + } + if e == "GIT_CONFIG_KEY_0=http.https://github.com/example/repo.git.extraHeader" { + gotKey = true + } + } + if !gotKey { + t.Errorf("extraHeader config key not set; env=%v", env) + } + if !gotHeader { + t.Errorf("expected %q in env, got %v", want, env) + } +} + +// TestMaterializeKeyFromEnv verifies that key_env writes the key material to +// a 0600 daemon-owned temp file, gitEnv points ssh at it, and cleanup removes +// it — the no-staging path for containers. +func TestMaterializeKeyFromEnv(t *testing.T) { + dir := t.TempDir() + const keyBody = "-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----" + t.Setenv("SSH_KEY", keyBody) + s := &Syncer{ + url: "git@github.com:example/repo.git", + auth: config.Auth{Method: config.AuthSSHKey, KeyEnv: "SSH_KEY"}, + dataDir: dir, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + cleanup, err := s.materializeKey() + if err != nil { + t.Fatalf("materializeKey: %v", err) + } + if s.keyPath == "" { + t.Fatal("keyPath not set") + } + fi, err := os.Stat(s.keyPath) + if err != nil { + t.Fatalf("stat key: %v", err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("key file mode = %o, want 0600", fi.Mode().Perm()) + } + got, _ := os.ReadFile(s.keyPath) + if string(got) != keyBody+"\n" { + t.Errorf("key content = %q, want body + trailing newline", got) + } + + env, err := s.gitEnv() + if err != nil { + t.Fatalf("gitEnv: %v", err) + } + var ok bool + for _, e := range env { + if strings.HasPrefix(e, "GIT_SSH_COMMAND=") && strings.Contains(e, s.keyPath) { + ok = true + } + } + if !ok { + t.Errorf("GIT_SSH_COMMAND missing materialized key path; env=%v", env) + } + + cleanup() + if _, err := os.Stat(s.keyPath); !os.IsNotExist(err) { + t.Error("cleanup did not remove the temp key file") + } +} + +// TestMaterializeKeyFromFileIsNoop verifies key_file is used in place (no +// temp file, cleanup is a no-op). +func TestMaterializeKeyFromFileIsNoop(t *testing.T) { + s := &Syncer{ + auth: config.Auth{Method: config.AuthSSHKey, KeyFile: "/etc/keys/id_ed25519"}, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + cleanup, err := s.materializeKey() + if err != nil { + t.Fatalf("materializeKey: %v", err) + } + defer cleanup() + if s.keyPath != "/etc/keys/id_ed25519" { + t.Errorf("keyPath = %q, want the key_file path", s.keyPath) + } +} + +func TestCacheDir(t *testing.T) { + const dataDir = "/data" + const url = "https://github.com/example/repo.git" + const branch = "main" + + tests := []struct { + name string + domain string + dataDir string + url string + branch string + }{ + {name: "site-a", domain: "a.example.com", dataDir: dataDir, url: url, branch: branch}, + {name: "site-b", domain: "b.example.com", dataDir: dataDir, url: url, branch: branch}, + {name: "site-a-alt-branch", domain: "a.example.com", dataDir: dataDir, url: url, branch: "dev"}, + {name: "site-a-alt-url", domain: "a.example.com", dataDir: dataDir, url: "https://github.com/example/other.git", branch: branch}, + } + + // Build all results first, then compare. + dirs := make([]string, len(tests)) + for i, tc := range tests { + dirs[i] = CacheDir(tc.domain, tc.dataDir, tc.url, tc.branch) + if dirs[i] == "" { + t.Errorf("[%s] CacheDir returned empty string", tc.name) + } + } + + // Stability: same inputs always produce the same path. + for i, tc := range tests { + got := CacheDir(tc.domain, tc.dataDir, tc.url, tc.branch) + if got != dirs[i] { + t.Errorf("[%s] CacheDir not stable: first=%q second=%q", tc.name, dirs[i], got) + } + } + + // Distinctness: every pair must produce a different path. + for i := range tests { + for j := i + 1; j < len(tests); j++ { + if dirs[i] == dirs[j] { + t.Errorf("CacheDir collision: [%s] and [%s] both produced %q", + tests[i].name, tests[j].name, dirs[i]) + } + } + } +} + +// TestCacheDirDomainIsolation is the core contract: same url+branch, different +// domain → different cache dir (prevents concurrent clone races, task 592). +func TestCacheDirDomainIsolation(t *testing.T) { + const dataDir = "/data" + const url = "https://github.com/example/repo.git" + const branch = "main" + + dirA := CacheDir("site-a.example.com", dataDir, url, branch) + dirB := CacheDir("site-b.example.com", dataDir, url, branch) + + if dirA == dirB { + t.Fatalf("expected distinct cache dirs for different domains; both got %q", dirA) + } +} + +// makeTar writes n regular files of the given payload into a tar stream. +func makeTar(files map[string][]byte) []byte { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for name, data := range files { + _ = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: name, + Size: int64(len(data)), + Mode: 0o640, + }) + _, _ = tw.Write(data) + } + _ = tw.Close() + return buf.Bytes() +} + +// newTestSyncer builds a minimal Syncer with explicit limits and no auth/log noise. +func newTestSyncer(limits config.Limits) *Syncer { + return &Syncer{ + limits: limits, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } +} + +func TestUntarMaxEntries(t *testing.T) { + s := newTestSyncer(config.Limits{ + MaxEntries: 2, + MaxUncompressedSize: config.ByteSize(config.DefaultMaxUncompressedSize), + }.Effective()) + + stream := makeTar(map[string][]byte{ + "a.txt": []byte("hello"), + "b.txt": []byte("world"), + "c.txt": []byte("extra"), + }) + + dest := t.TempDir() + err := s.untar(bytes.NewReader(stream), dest) + if err == nil { + t.Fatal("expected error for max_entries exceeded, got nil") + } + if !strings.Contains(err.Error(), "max_entries") { + t.Fatalf("expected max_entries error, got: %v", err) + } +} + +func TestUntarMaxUncompressedSize(t *testing.T) { + const limit = 50 + + s := newTestSyncer(config.Limits{ + MaxEntries: config.DefaultMaxEntries, + MaxUncompressedSize: limit, + }.Effective()) + + stream := makeTar(map[string][]byte{ + "big.txt": bytes.Repeat([]byte("x"), limit+1), + }) + + dest := t.TempDir() + err := s.untar(bytes.NewReader(stream), dest) + if err == nil { + t.Fatal("expected error for max_uncompressed_size exceeded, got nil") + } + if !strings.Contains(err.Error(), "max_uncompressed_size") { + t.Fatalf("expected max_uncompressed_size error, got: %v", err) + } +} + +func TestUntarWithinLimits(t *testing.T) { + s := newTestSyncer(config.Limits{ + MaxEntries: 5, + MaxUncompressedSize: 1024, + }.Effective()) + + stream := makeTar(map[string][]byte{ + "a.txt": []byte("hello"), + "b.txt": []byte("world"), + }) + + dest := t.TempDir() + if err := s.untar(bytes.NewReader(stream), dest); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestUntarTraversalRejected verifies that an archive entry whose path +// escapes the staging directory is rejected before any file is written. +func TestUntarTraversalRejected(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + _ = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "../escape.txt", + Size: 5, + Mode: 0o640, + }) + _, _ = tw.Write([]byte("oops!")) + _ = tw.Close() + + s := newTestSyncer(config.Limits{}.Effective()) + err := s.untar(bytes.NewReader(buf.Bytes()), t.TempDir()) + if err == nil { + t.Fatal("expected error for path traversal, got nil") + } + if !strings.Contains(err.Error(), "escapes") { + t.Fatalf("expected 'escapes' in error, got: %v", err) + } +} + +// TestUntarSymlinkSkipped verifies that TypeSymlink entries are skipped +// (logged as warnings) and do not cause extraction to fail. +func TestUntarSymlinkSkipped(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + _ = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeSymlink, + Name: "link", + Linkname: "/etc/passwd", + Mode: 0o777, + }) + _ = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "real.txt", + Size: 5, + Mode: 0o640, + }) + _, _ = tw.Write([]byte("hello")) + _ = tw.Close() + + s := newTestSyncer(config.Limits{}.Effective()) + dest := t.TempDir() + if err := s.untar(bytes.NewReader(buf.Bytes()), dest); err != nil { + t.Fatalf("symlink entry should be skipped, not error: %v", err) + } + + // The symlink must not have been created. + if _, err := os.Lstat(dest + "/link"); !os.IsNotExist(err) { + t.Error("symlink entry was materialized; it should have been skipped") + } + // The regular file alongside it must be present. + if _, err := os.Stat(dest + "/real.txt"); err != nil { + t.Errorf("real.txt missing after symlink skip: %v", err) + } +} + +// TestUntarHardlinkSkipped verifies that TypeLink (hard-link) entries are +// skipped without error — hard links could escape the staging tree. +func TestUntarHardlinkSkipped(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + _ = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "original.txt", + Size: 3, + Mode: 0o640, + }) + _, _ = tw.Write([]byte("abc")) + _ = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeLink, + Name: "hardlink", + Linkname: "original.txt", + }) + _ = tw.Close() + + s := newTestSyncer(config.Limits{}.Effective()) + dest := t.TempDir() + if err := s.untar(bytes.NewReader(buf.Bytes()), dest); err != nil { + t.Fatalf("hard-link entry should be skipped, not error: %v", err) + } + + // Hard link must not exist. + if _, err := os.Lstat(dest + "/hardlink"); !os.IsNotExist(err) { + t.Error("hard-link entry was materialized; it should have been skipped") + } +} + +// TestUntarSubdirPrefixStripping verifies that when a Syncer is configured +// with a subdir, entries under that prefix are extracted with the prefix +// removed and entries outside it are ignored. +func TestUntarSubdirPrefixStripping(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for name, data := range map[string][]byte{ + "site/index.html": []byte(""), + "site/css/main.css": []byte("body{}"), + "other/skip.txt": []byte("ignored"), + } { + _ = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: name, + Size: int64(len(data)), + Mode: 0o640, + }) + _, _ = tw.Write(data) + } + _ = tw.Close() + + s := &Syncer{ + subdir: "site", + limits: config.Limits{}.Effective(), + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + dest := t.TempDir() + if err := s.untar(bytes.NewReader(buf.Bytes()), dest); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Files under site/ must exist without the prefix. + for _, name := range []string{"index.html", "css/main.css"} { + if _, err := os.Stat(dest + "/" + name); err != nil { + t.Errorf("expected %q to be extracted: %v", name, err) + } + } + // Files outside the subdir must not appear. + if _, err := os.Stat(dest + "/other"); !os.IsNotExist(err) { + t.Error("entry outside subdir was extracted; it should have been skipped") + } + if _, err := os.Stat(dest + "/skip.txt"); !os.IsNotExist(err) { + t.Error("entry outside subdir was extracted; it should have been skipped") + } +} diff --git a/nginxpilot/internal/source/httpzip/download_test.go b/nginxpilot/internal/source/httpzip/download_test.go new file mode 100644 index 00000000..4856bfb4 --- /dev/null +++ b/nginxpilot/internal/source/httpzip/download_test.go @@ -0,0 +1,75 @@ +package httpzip + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "log/slog" + "os" + "sync" + "testing" + + "github.com/kalevski/toolcase/nginxpilot/internal/config" +) + +// TestDownloadConcurrentDistinctPaths verifies that two concurrent download +// calls for the same domain write to distinct temp files and produce the +// correct hash for the content each received. +func TestDownloadConcurrentDistinctPaths(t *testing.T) { + t.Parallel() + + content := buildZip(t) + h := sha256.Sum256(content) + wantHash := hex.EncodeToString(h[:]) + + syncer := &Syncer{ + domain: "example.com", + dataDir: t.TempDir(), + limits: config.Limits{}.Effective(), + log: slog.Default(), + } + + type result struct { + path string + hash string + err error + } + + var wg sync.WaitGroup + ch := make(chan result, 2) + + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + path, hash, _, err := syncer.download(bytes.NewReader(content)) + if path != "" { + t.Cleanup(func() { os.Remove(path) }) + } + ch <- result{path: path, hash: hash, err: err} + }() + } + + wg.Wait() + close(ch) + + var results []result + for r := range ch { + results = append(results, r) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + for i, r := range results { + if r.err != nil { + t.Errorf("goroutine %d: download error: %v", i, r.err) + } + if r.hash != wantHash { + t.Errorf("goroutine %d: hash mismatch: got %s, want %s", i, r.hash, wantHash) + } + } + if results[0].path == results[1].path { + t.Errorf("concurrent downloads wrote to the same path: %s", results[0].path) + } +} diff --git a/nginxpilot/internal/source/httpzip/extract_test.go b/nginxpilot/internal/source/httpzip/extract_test.go new file mode 100644 index 00000000..5137e7cb --- /dev/null +++ b/nginxpilot/internal/source/httpzip/extract_test.go @@ -0,0 +1,388 @@ +package httpzip + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kalevski/toolcase/nginxpilot/internal/config" +) + +// newExtractSyncer builds a minimal Syncer for extract / resolveStrip tests. +func newExtractSyncer(limits config.Limits, strip *int) *Syncer { + return &Syncer{ + limits: limits, + strip: strip, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } +} + +// writeZipEntries writes a zip archive to a temp file and returns the path and +// its size on disk. entries maps name → content. +func writeZipEntries(t *testing.T, entries map[string][]byte) (string, int64) { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, data := range entries { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(data); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + f, err := os.CreateTemp(t.TempDir(), "*.zip") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(buf.Bytes()); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return f.Name(), int64(buf.Len()) +} + +// writeZipWithSymlink creates a zip that has a single entry with the symlink +// mode bit set. +func writeZipWithSymlink(t *testing.T, name string) (string, int64) { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + h := &zip.FileHeader{Name: name, Method: zip.Deflate} + h.SetMode(os.ModeSymlink | 0o777) + w, err := zw.CreateHeader(h) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("/etc/passwd")); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + f, err := os.CreateTemp(t.TempDir(), "*.zip") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(buf.Bytes()); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return f.Name(), int64(buf.Len()) +} + +// buildZipWithFakeDeclaredSize creates a valid zip containing realData under +// name, then patches the central directory's uncompressed-size field to +// fakeSize so that the pre-flight declared-total check sees a small number +// while the actual bytes written exceed it. +func buildZipWithFakeDeclaredSize(t *testing.T, name string, realData []byte, fakeSize uint32) (string, int64) { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(realData); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + raw := buf.Bytes() + + // Central directory entry signature: PK\x01\x02 (LE 0x02014b50). + // Uncompressed size sits at offset 24 from the signature start. + cdSig := []byte{0x50, 0x4b, 0x01, 0x02} + idx := bytes.Index(raw, cdSig) + if idx < 0 { + t.Fatal("central directory signature not found in zip bytes") + } + binary.LittleEndian.PutUint32(raw[idx+24:], fakeSize) + + f, err := os.CreateTemp(t.TempDir(), "*.zip") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(raw); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return f.Name(), int64(len(raw)) +} + +// ---- cleanEntryName ------------------------------------------------------- + +func TestCleanEntryName(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + errFrag string + }{ + {name: "normal file", input: "foo/bar.txt", want: "foo/bar.txt"}, + {name: "trailing slash stripped", input: "foo/", want: "foo"}, + {name: "dot only", input: ".", want: "."}, + {name: "empty", input: "", want: "."}, + {name: "dot-slash normalized", input: "./", want: "."}, + {name: "absolute path", input: "/etc/passwd", wantErr: true, errFrag: "zip-slip"}, + {name: "double-dot", input: "..", wantErr: true, errFrag: "zip-slip"}, + {name: "dotdot prefix", input: "../etc/passwd", wantErr: true, errFrag: "zip-slip"}, + {name: "interior dotdot", input: "a/../../b", wantErr: true, errFrag: "zip-slip"}, + {name: "backslash converted to slash", input: `foo\bar.txt`, want: "foo/bar.txt"}, + // C:\... on Linux becomes "C:/..." which is a safe relative path (no absolute root) + // so it is NOT rejected. Only genuine Unix absolute paths (leading /) are caught. + {name: "NUL byte rejected", input: "foo\x00bar", wantErr: true, errFrag: "NUL"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := cleanEntryName(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got %q", tc.input, got) + } + if tc.errFrag != "" && !strings.Contains(err.Error(), tc.errFrag) { + t.Fatalf("expected error containing %q, got: %v", tc.errFrag, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.input, err) + } + if got != tc.want { + t.Errorf("cleanEntryName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +// ---- resolveStrip --------------------------------------------------------- + +func ptrInt(v int) *int { return &v } + +func TestResolveStrip(t *testing.T) { + tests := []struct { + name string + strip *int + names []string + want int + }{ + { + name: "explicit strip=0 forces off even with shared root", + strip: ptrInt(0), + names: []string{"root/a.txt", "root/b.txt"}, + want: 0, + }, + { + name: "explicit strip=2", + strip: ptrInt(2), + names: []string{"a/b/c.txt"}, + want: 2, + }, + { + name: "single shared root auto-stripped", + strip: nil, + names: []string{"root/a.txt", "root/b.txt", "root/sub/c.txt"}, + want: 1, + }, + { + name: "mixed roots disable auto-strip", + strip: nil, + names: []string{"root1/a.txt", "root2/b.txt"}, + want: 0, + }, + { + name: "bare top-level file disables auto-strip", + strip: nil, + names: []string{"README.md", "root/a.txt"}, + want: 0, + }, + { + name: "only dot entries produce no strip", + strip: nil, + names: []string{"."}, + want: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := newExtractSyncer(config.Limits{}.Effective(), tc.strip) + got := s.resolveStrip(tc.names) + if got != tc.want { + t.Errorf("resolveStrip(%v) = %d, want %d", tc.names, got, tc.want) + } + }) + } +} + +// ---- extract limits ------------------------------------------------------- + +func defaultExtractLimits() config.Limits { return config.Limits{}.Effective() } + +func TestExtractMaxEntries(t *testing.T) { + s := newExtractSyncer(config.Limits{ + MaxEntries: 2, + MaxUncompressedSize: config.DefaultMaxUncompressedSize, + MaxCompressionRatio: config.DefaultMaxCompressionRatio, + MaxArchiveSize: config.DefaultMaxArchiveSize, + }.Effective(), nil) + + path, size := writeZipEntries(t, map[string][]byte{ + "a.txt": []byte("a"), + "b.txt": []byte("b"), + "c.txt": []byte("c"), + }) + + err := s.extract(path, size, t.TempDir()) + if err == nil { + t.Fatal("expected error for max_entries exceeded, got nil") + } + if !strings.Contains(err.Error(), "max_entries") { + t.Fatalf("expected max_entries error, got: %v", err) + } +} + +func TestExtractMaxUncompressedSize(t *testing.T) { + const limit = 10 + s := newExtractSyncer(config.Limits{ + MaxEntries: config.DefaultMaxEntries, + MaxUncompressedSize: limit, + MaxCompressionRatio: config.DefaultMaxCompressionRatio, + MaxArchiveSize: config.DefaultMaxArchiveSize, + }.Effective(), nil) + + path, size := writeZipEntries(t, map[string][]byte{ + "big.txt": bytes.Repeat([]byte("x"), limit+1), + }) + + err := s.extract(path, size, t.TempDir()) + if err == nil { + t.Fatal("expected error for max_uncompressed_size exceeded, got nil") + } + if !strings.Contains(err.Error(), "max_uncompressed_size") { + t.Fatalf("expected max_uncompressed_size error, got: %v", err) + } +} + +func TestExtractCompressionRatioLimit(t *testing.T) { + // Build a zip with highly-compressible content so the ratio far exceeds 1. + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("zeros.bin") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(bytes.Repeat([]byte{0}, 8192)); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + raw := buf.Bytes() + f, err := os.CreateTemp(t.TempDir(), "*.zip") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(raw); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + s := newExtractSyncer(config.Limits{ + MaxEntries: config.DefaultMaxEntries, + MaxUncompressedSize: config.DefaultMaxUncompressedSize, + MaxCompressionRatio: 1, // trigger: actual ratio >> 1 + MaxArchiveSize: config.DefaultMaxArchiveSize, + }.Effective(), nil) + + err = s.extract(f.Name(), int64(len(raw)), t.TempDir()) + if err == nil { + t.Fatal("expected error for max_compression_ratio exceeded, got nil") + } + if !strings.Contains(err.Error(), "max_compression_ratio") { + t.Fatalf("expected max_compression_ratio error, got: %v", err) + } +} + +func TestExtractSymlinkRejected(t *testing.T) { + s := newExtractSyncer(defaultExtractLimits(), nil) + + path, size := writeZipWithSymlink(t, "link.txt") + err := s.extract(path, size, t.TempDir()) + if err == nil { + t.Fatal("expected error for symlink entry, got nil") + } + if !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink error, got: %v", err) + } +} + +func TestExtractOK(t *testing.T) { + s := newExtractSyncer(defaultExtractLimits(), nil) + + dest := t.TempDir() + // root/ is the shared prefix and will be auto-stripped. + path, size := writeZipEntries(t, map[string][]byte{ + "root/index.html": []byte(""), + "root/css/main.css": []byte("body{}"), + }) + + if err := s.extract(path, size, dest); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wantFile := filepath.Join(dest, "index.html") + if _, err := os.Stat(wantFile); err != nil { + t.Errorf("expected %s to exist after extraction: %v", wantFile, err) + } +} + +// TestExtractBeltAndBraces verifies the second uncompressed-size guard +// (extract.go:88-92) fires when a crafted archive declares a small +// uncompressed size in the central directory header but actually contains +// more bytes — the declared-total pre-flight passes, but actual writes exceed +// the cap. +func TestExtractBeltAndBraces(t *testing.T) { + const maxUncomp = 10 + const fakeSize = 5 // claimed in central directory — passes pre-flight + const realSize = 100 // actual decompressed bytes — exceeds cap + + path, size := buildZipWithFakeDeclaredSize(t, "evil.bin", + bytes.Repeat([]byte("X"), realSize), fakeSize) + + s := newExtractSyncer(config.Limits{ + MaxEntries: config.DefaultMaxEntries, + MaxUncompressedSize: maxUncomp, + MaxCompressionRatio: config.DefaultMaxCompressionRatio, + MaxArchiveSize: config.DefaultMaxArchiveSize, + }.Effective(), nil) + + err := s.extract(path, size, t.TempDir()) + if err == nil { + t.Fatal("expected belt-and-braces size error, got nil") + } + if !strings.Contains(err.Error(), "max_uncompressed_size") { + t.Fatalf("expected max_uncompressed_size error, got: %v", err) + } +} diff --git a/nginxpilot/internal/source/httpzip/httpzip.go b/nginxpilot/internal/source/httpzip/httpzip.go index 3ead4e58..3b10573c 100644 --- a/nginxpilot/internal/source/httpzip/httpzip.go +++ b/nginxpilot/internal/source/httpzip/httpzip.go @@ -12,6 +12,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -53,6 +54,12 @@ func New(domain string, src config.Source, dataDir string, log *slog.Logger) *Sy if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } + if len(via) > 0 && req.URL.Host != via[0].URL.Host { + req.Header.Del("Authorization") + if src.Auth.MethodOrNone() == config.AuthHeader && src.Auth.Name != "" { + req.Header.Del(src.Auth.Name) + } + } return nil }, }, @@ -137,18 +144,18 @@ func (s *Syncer) Sync(ctx context.Context, st *state.SiteState, stagingDir strin }, nil } -// download streams the body to /tmp/.zip.partial, +// download streams the body to a unique temp file under /tmp/, // enforcing max_archive_size and computing SHA-256 on the fly. func (s *Syncer) download(body io.Reader) (path string, hash string, size int64, err error) { tmpDir := filepath.Join(s.dataDir, "tmp") if err := os.MkdirAll(tmpDir, 0o750); err != nil { return "", "", 0, err } - path = filepath.Join(tmpDir, s.domain+".zip.partial") - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640) + f, err := os.CreateTemp(tmpDir, s.domain+"-*.zip.partial") if err != nil { return "", "", 0, err } + path = f.Name() maxSize := int64(s.limits.MaxArchiveSize) hasher := sha256.New() @@ -176,8 +183,12 @@ func (s *Syncer) verifyChecksum(ctx context.Context, bodyHash string) error { if err != nil { return err } - if err := s.applyAuth(req); err != nil { - return err + // Only forward auth when the checksum host matches the artifact host to + // prevent leaking credentials to a different server. + if sameHost(s.url, s.checksumURL) { + if err := s.applyAuth(req); err != nil { + return err + } } resp, err := s.client.Do(req) if err != nil { @@ -205,6 +216,19 @@ func (s *Syncer) verifyChecksum(ctx context.Context, bodyHash string) error { return nil } +// sameHost reports whether two raw URLs share the same host (scheme+host+port). +func sameHost(a, b string) bool { + ua, err := url.Parse(a) + if err != nil { + return false + } + ub, err := url.Parse(b) + if err != nil { + return false + } + return ua.Host == ub.Host +} + func (s *Syncer) applyAuth(req *http.Request) error { switch s.auth.MethodOrNone() { case config.AuthNone: diff --git a/nginxpilot/internal/source/httpzip/redirect_test.go b/nginxpilot/internal/source/httpzip/redirect_test.go new file mode 100644 index 00000000..c4c5b0cf --- /dev/null +++ b/nginxpilot/internal/source/httpzip/redirect_test.go @@ -0,0 +1,234 @@ +package httpzip + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kalevski/toolcase/nginxpilot/internal/config" + "github.com/kalevski/toolcase/nginxpilot/internal/state" +) + +// buildZip returns a minimal valid zip archive. +func buildZip(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, err := w.Create("index.html") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("")); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// TestCheckRedirectStripsCustomHeaderOnCrossHostRedirect verifies that a +// cross-host redirect does not forward the custom auth header (X-Api-Key) to +// the second host. +func TestCheckRedirectStripsCustomHeaderOnCrossHostRedirect(t *testing.T) { + const headerName = "X-Api-Key" + const headerValue = "super-secret" + + zipBody := buildZip(t) + receivedOnB := make(chan string, 1) + + // Server B: the redirect target — records the custom header value. + serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedOnB <- r.Header.Get(headerName) + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + w.Write(zipBody) + })) + defer serverB.Close() + + // Server A: responds with a redirect to server B (different host:port). + serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, serverB.URL+"/artifact.zip", http.StatusFound) + })) + defer serverA.Close() + + src := config.Source{ + Type: config.SourceHTTPZip, + URL: serverA.URL + "/artifact.zip", + Auth: config.Auth{ + Method: config.AuthHeader, + Name: headerName, + ValueEnv: "HTTPZIP_TEST_XHDR", + }, + } + t.Setenv("HTTPZIP_TEST_XHDR", headerValue) + + syncer := New("test.example.com", src, t.TempDir(), slog.Default()) + + _, err := syncer.Sync(context.Background(), &state.SiteState{}, t.TempDir()) + if err != nil { + t.Fatalf("Sync returned error: %v", err) + } + + got := <-receivedOnB + if got != "" { + t.Errorf("custom header %q leaked to redirect target; got %q", headerName, got) + } +} + +// TestCheckRedirectPreservesCustomHeaderOnSameHostRedirect verifies that a +// same-host redirect (same host:port) keeps the custom auth header. +func TestCheckRedirectPreservesCustomHeaderOnSameHostRedirect(t *testing.T) { + const headerName = "X-Api-Key" + const headerValue = "super-secret" + + zipBody := buildZip(t) + receivedOnFinal := make(chan string, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/real.zip", func(w http.ResponseWriter, r *http.Request) { + receivedOnFinal <- r.Header.Get(headerName) + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + w.Write(zipBody) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + // Register the redirect handler after the server URL is known so the + // redirect target URL uses the real port. + mux.HandleFunc("/artifact.zip", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, server.URL+"/real.zip", http.StatusFound) + }) + + src := config.Source{ + Type: config.SourceHTTPZip, + URL: server.URL + "/artifact.zip", + Auth: config.Auth{ + Method: config.AuthHeader, + Name: headerName, + ValueEnv: "HTTPZIP_TEST_XHDR_SAME", + }, + } + t.Setenv("HTTPZIP_TEST_XHDR_SAME", headerValue) + + syncer := New("test.example.com", src, t.TempDir(), slog.Default()) + + _, err := syncer.Sync(context.Background(), &state.SiteState{}, t.TempDir()) + if err != nil { + t.Fatalf("Sync returned error: %v", err) + } + + got := <-receivedOnFinal + if got != headerValue { + t.Errorf("custom header should be preserved on same-host redirect; got %q, want %q", got, headerValue) + } +} + +// TestVerifyChecksumSkipsAuthOnDifferentHost verifies that auth credentials +// are not sent to a checksum server whose host differs from the artifact host. +func TestVerifyChecksumSkipsAuthOnDifferentHost(t *testing.T) { + const headerName = "X-Api-Key" + const headerValue = "super-secret" + + zipBody := buildZip(t) + h := sha256.Sum256(zipBody) + zipHash := hex.EncodeToString(h[:]) + + checksumHeaderReceived := make(chan string, 1) + + // Artifact server: returns the zip. + artifactServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + w.Write(zipBody) + })) + defer artifactServer.Close() + + // Checksum server: different host:port — records the auth header value. + checksumServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + checksumHeaderReceived <- r.Header.Get(headerName) + w.Write([]byte(zipHash + " artifact.zip\n")) + })) + defer checksumServer.Close() + + src := config.Source{ + Type: config.SourceHTTPZip, + URL: artifactServer.URL + "/artifact.zip", + ChecksumURL: checksumServer.URL + "/artifact.zip.sha256", + Auth: config.Auth{ + Method: config.AuthHeader, + Name: headerName, + ValueEnv: "HTTPZIP_TEST_XHDR_CKSUM", + }, + } + t.Setenv("HTTPZIP_TEST_XHDR_CKSUM", headerValue) + + syncer := New("test.example.com", src, t.TempDir(), slog.Default()) + + _, err := syncer.Sync(context.Background(), &state.SiteState{}, t.TempDir()) + if err != nil { + t.Fatalf("Sync returned error: %v", err) + } + + got := <-checksumHeaderReceived + if got != "" { + t.Errorf("auth header %q was sent to cross-host checksum server; got %q", headerName, got) + } +} + +// TestVerifyChecksumSendsAuthOnSameHost verifies that auth credentials ARE +// forwarded to a checksum server that shares the artifact host. +func TestVerifyChecksumSendsAuthOnSameHost(t *testing.T) { + const headerName = "X-Api-Key" + const headerValue = "super-secret" + + zipBody := buildZip(t) + h := sha256.Sum256(zipBody) + zipHash := hex.EncodeToString(h[:]) + + checksumHeaderReceived := make(chan string, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/artifact.zip", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + w.Write(zipBody) + }) + mux.HandleFunc("/artifact.zip.sha256", func(w http.ResponseWriter, r *http.Request) { + checksumHeaderReceived <- r.Header.Get(headerName) + w.Write([]byte(zipHash + " artifact.zip\n")) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + src := config.Source{ + Type: config.SourceHTTPZip, + URL: server.URL + "/artifact.zip", + ChecksumURL: server.URL + "/artifact.zip.sha256", + Auth: config.Auth{ + Method: config.AuthHeader, + Name: headerName, + ValueEnv: "HTTPZIP_TEST_XHDR_CKSUM_SAME", + }, + } + t.Setenv("HTTPZIP_TEST_XHDR_CKSUM_SAME", headerValue) + + syncer := New("test.example.com", src, t.TempDir(), slog.Default()) + + _, err := syncer.Sync(context.Background(), &state.SiteState{}, t.TempDir()) + if err != nil { + t.Fatalf("Sync returned error: %v", err) + } + + got := <-checksumHeaderReceived + if got != headerValue { + t.Errorf("auth header should be sent to same-host checksum server; got %q, want %q", got, headerValue) + } +} diff --git a/nginxpilot/internal/state/state.go b/nginxpilot/internal/state/state.go index dd754173..055df318 100644 --- a/nginxpilot/internal/state/state.go +++ b/nginxpilot/internal/state/state.go @@ -79,19 +79,37 @@ func (s *Store) Load(domain string) (*SiteState, error) { return &st, nil } -// Save writes the state atomically (temp file + rename). +// Save writes the state crash-durably: fsync the temp file before rename, +// then fsync the directory so the new directory entry survives a power loss. func (s *Store) Save(st *SiteState) error { raw, err := json.MarshalIndent(st, "", " ") if err != nil { return err } tmp := s.path(st.Domain) + ".tmp" - if err := os.WriteFile(tmp, raw, 0o640); err != nil { + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640) + if err != nil { + return fmt.Errorf("write state for %s: %w", st.Domain, err) + } + if _, err := f.Write(raw); err != nil { + f.Close() return fmt.Errorf("write state for %s: %w", st.Domain, err) } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } if err := os.Rename(tmp, s.path(st.Domain)); err != nil { return fmt.Errorf("commit state for %s: %w", st.Domain, err) } + d, err := os.Open(s.dir) + if err == nil { + _ = d.Sync() + _ = d.Close() + } return nil } diff --git a/nginxpilot/internal/state/state_test.go b/nginxpilot/internal/state/state_test.go new file mode 100644 index 00000000..ef8dd311 --- /dev/null +++ b/nginxpilot/internal/state/state_test.go @@ -0,0 +1,86 @@ +package state + +import ( + "testing" + "time" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + want := &SiteState{ + Domain: "example.com", + SourceFingerprint: "fp-abc123", + DeployedRef: "deadbeef", + ETag: `"etag-1"`, + LastModified: "Wed, 21 Jun 2026 00:00:00 GMT", + ContentHash: "sha256hash", + LastSuccess: time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC), + LastError: "previous error", + LastErrorTime: time.Date(2026, 6, 20, 8, 0, 0, 0, time.UTC), + FailureStreak: 3, + } + + if err := store.Save(want); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := store.Load(want.Domain) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if got.Domain != want.Domain { + t.Errorf("Domain: got %q, want %q", got.Domain, want.Domain) + } + if got.SourceFingerprint != want.SourceFingerprint { + t.Errorf("SourceFingerprint: got %q, want %q", got.SourceFingerprint, want.SourceFingerprint) + } + if got.DeployedRef != want.DeployedRef { + t.Errorf("DeployedRef: got %q, want %q", got.DeployedRef, want.DeployedRef) + } + if got.ETag != want.ETag { + t.Errorf("ETag: got %q, want %q", got.ETag, want.ETag) + } + if got.LastModified != want.LastModified { + t.Errorf("LastModified: got %q, want %q", got.LastModified, want.LastModified) + } + if got.ContentHash != want.ContentHash { + t.Errorf("ContentHash: got %q, want %q", got.ContentHash, want.ContentHash) + } + if !got.LastSuccess.Equal(want.LastSuccess) { + t.Errorf("LastSuccess: got %v, want %v", got.LastSuccess, want.LastSuccess) + } + if got.LastError != want.LastError { + t.Errorf("LastError: got %q, want %q", got.LastError, want.LastError) + } + if !got.LastErrorTime.Equal(want.LastErrorTime) { + t.Errorf("LastErrorTime: got %v, want %v", got.LastErrorTime, want.LastErrorTime) + } + if got.FailureStreak != want.FailureStreak { + t.Errorf("FailureStreak: got %d, want %d", got.FailureStreak, want.FailureStreak) + } +} + +func TestLoadMissing(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + st, err := store.Load("nonexistent.com") + if err != nil { + t.Fatalf("Load of missing domain should not error: %v", err) + } + if st.Domain != "nonexistent.com" { + t.Errorf("expected domain to be set, got %q", st.Domain) + } + if !st.NeverSynced() { + t.Error("expected NeverSynced() == true for fresh state") + } +} diff --git a/node/.DS_Store b/node/.DS_Store deleted file mode 100644 index 211b4936..00000000 Binary files a/node/.DS_Store and /dev/null differ diff --git a/node/package.json b/node/package.json index 8b8a268b..3df6bee3 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@toolcase/node", - "version": "4.1.0", + "version": "5.0.0", "description": "Node.js helpers for backend services — Fastify endpoints, raw-SQL repositories, Redis KV service, and isomorphic sanitize/pagination/where helpers.", "source": "src/main.ts", "main": "lib/main.main.js", @@ -25,8 +25,8 @@ }, "peerDependencies": { "@fastify/cors": "^11.0.0", - "@toolcase/base": "^3.0.0", - "@toolcase/serializer": "^3.0.0", + "@toolcase/base": "^5.0.0", + "@toolcase/serializer": "^5.0.0", "fastify": "^5.0.0", "jose": "^5.10.0", "redis": "^5.0.0", diff --git a/node/src/AtlasBuilder.ts b/node/src/AtlasBuilder.ts index 00418d6b..76529b38 100644 --- a/node/src/AtlasBuilder.ts +++ b/node/src/AtlasBuilder.ts @@ -36,6 +36,8 @@ export interface AtlasBuilderOptions { * whole-atlas concerns, not per-input. */ continueOnError?: boolean + /** Maximum allowed input pixels (width × height) per source image. Defaults to 50 000 000. */ + maxInputPixels?: number } export interface AtlasBuildFailure { @@ -202,9 +204,10 @@ export class AtlasBuilder { if (!decoded) continue if (placed.rect.width <= 0 || placed.rect.height <= 0) continue - const overlayBuffer = await this.prepareOverlay(sharp, decoded, placed.rotated) + const overlay = await this.prepareOverlay(sharp, decoded, placed.rotated) composites.push({ - input: overlayBuffer, + input: overlay.input, + raw: overlay.raw, left: placed.rect.x, top: placed.rect.y }) @@ -238,7 +241,7 @@ export class AtlasBuilder { channels: 4, background: this.options.background ?? { r: 0, g: 0, b: 0, alpha: 0 } } - }).composite(composites) + }, { limitInputPixels: this.options.maxInputPixels ?? 50_000_000, failOn: 'error' }).composite(composites) const composedBuffer = await canvas.png().toBuffer() let writer = ImageProcessor.fromBuffer(composedBuffer).format({ @@ -297,8 +300,13 @@ export class AtlasBuilder { throw new AtlasBuildError('decode', 'input.path is required') } const id = typeof input.id === 'string' && input.id.length > 0 ? input.id : path.basename(input.path, path.extname(input.path)) + const limit = this.options.maxInputPixels ?? 50_000_000 try { - const { data, info } = await sharp(input.path).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) + const meta = await sharp(input.path, { limitInputPixels: limit, failOn: 'error' }).metadata() + if (meta.width && meta.height && meta.width * meta.height > limit) { + throw new AtlasBuildError('decode', `input image exceeds pixel limit (${meta.width * meta.height} > ${limit})`, input.path) + } + const { data, info } = await sharp(input.path, { limitInputPixels: limit, failOn: 'error' }).rotate().ensureAlpha().raw().toBuffer({ resolveWithObject: true }) const useTrim = this.options.useAlphaTrimming !== false const bounds = useTrim ? AtlasBuilder.computeAlphaBounds(data, info.width, info.height, this.options.packer?.alphaThreshold ?? 1) @@ -321,16 +329,20 @@ export class AtlasBuilder { } } - private async prepareOverlay(sharp: SharpFactory, decoded: DecodedImage, rotated: boolean): Promise { - const raw = sharp(decoded.trimmedBuffer, { - raw: { - width: decoded.trimmedWidth, - height: decoded.trimmedHeight, - channels: 4 + private async prepareOverlay(sharp: SharpFactory, decoded: DecodedImage, rotated: boolean): Promise<{ input: Buffer; raw: { width: number; height: number; channels: 1 | 2 | 3 | 4 } }> { + if (!rotated) { + return { + input: decoded.trimmedBuffer, + raw: { width: decoded.trimmedWidth, height: decoded.trimmedHeight, channels: 4 } } - }) - const withRotation = rotated ? raw.rotate(-90) : raw - return await withRotation.png().toBuffer() + } + const { data, info } = await sharp(decoded.trimmedBuffer, { + raw: { width: decoded.trimmedWidth, height: decoded.trimmedHeight, channels: 4 } + }).rotate(-90).raw().toBuffer({ resolveWithObject: true }) + return { + input: data, + raw: { width: info.width, height: info.height, channels: info.channels } + } } private static computeAlphaBounds(data: Buffer, width: number, height: number, threshold: number): { left: number; top: number; width: number; height: number } { diff --git a/node/src/BaseRepository.ts b/node/src/BaseRepository.ts index a3dd2c7e..c2f487c8 100644 --- a/node/src/BaseRepository.ts +++ b/node/src/BaseRepository.ts @@ -94,20 +94,22 @@ export abstract class BaseRepository { } private report(label: string, elapsed: number, error?: unknown): void { - if (elapsed >= this.slowQueryMs) { - this.logger?.warn?.(`slow query: ${this.source}.${label} took ${elapsed}ms`, { - source: this.source, - op: label, - durationMs: elapsed, - }) - } else { - this.logger?.debug?.(`${this.source}.${label} ${elapsed}ms`, { - source: this.source, - op: label, - durationMs: elapsed, - }) - } - this.onOperation?.({ source: this.source, op: label, durationMs: elapsed, error }) + try { + if (elapsed >= this.slowQueryMs) { + this.logger?.warn?.(`slow query: ${this.source}.${label} took ${elapsed}ms`, { + source: this.source, + op: label, + durationMs: elapsed, + }) + } else { + this.logger?.debug?.(`${this.source}.${label} ${elapsed}ms`, { + source: this.source, + op: label, + durationMs: elapsed, + }) + } + this.onOperation?.({ source: this.source, op: label, durationMs: elapsed, error }) + } catch { /* never let telemetry mask the operation */ } } /** diff --git a/node/src/EntityService.ts b/node/src/EntityService.ts index f49ff289..6883c350 100644 --- a/node/src/EntityService.ts +++ b/node/src/EntityService.ts @@ -51,35 +51,36 @@ export abstract class EntityService { protected afterDelete(_count: number, _ctx: HookContext & { id?: any; where?: Filter }): Promise | void {} async insert(values: Record, trx?: Driver): Promise { - const ctx: HookContext = { trx } - const prepared = await this.beforeInsert(values, ctx) - const row = await this.repository.insert(prepared, trx) - return this.afterInsert(row, ctx) + return this.withTrx(trx, async (t) => { + const ctx: HookContext = { trx: t } + const prepared = await this.beforeInsert(values, ctx) + const row = await this.repository.insert(prepared, t) + return this.afterInsert(row, ctx) + }) } async upsert(values: Record, options: UpsertOptions, trx?: Driver): Promise { - const ctx: HookContext = { trx } - const prepared = await this.beforeInsert(values, ctx) - const row = await this.repository.upsert(prepared, options, trx) - return this.afterInsert(row, ctx) + return this.withTrx(trx, async (t) => { + const ctx: HookContext = { trx: t } + const prepared = await this.beforeInsert(values, ctx) + const row = await this.repository.upsert(prepared, options, t) + return this.afterInsert(row, ctx) + }) } /** - * Bulk insert. `beforeInsert` and `afterInsert` hooks run concurrently via + * Bulk insert. `beforeInsert` and `afterInsert` hooks run unconditionally via * `Promise.all`; result order is preserved by index. Override hooks must be * safe to invoke in parallel — do not rely on serial side effects. */ async insertMany(values: Record[], trx?: Driver): Promise { if (values.length === 0) return [] - const ctx: HookContext = { trx } - const hasBefore = this.beforeInsert !== EntityService.prototype.beforeInsert - const hasAfter = this.afterInsert !== EntityService.prototype.afterInsert - const prepared = hasBefore - ? await Promise.all(values.map((v) => this.beforeInsert(v, ctx))) - : values - const rows = await this.repository.insertMany(prepared, trx) - if (!hasAfter) return rows - return Promise.all(rows.map((row) => this.afterInsert(row, ctx))) + return this.withTrx(trx, async (t) => { + const ctx: HookContext = { trx: t } + const prepared = await Promise.all(values.map((v) => this.beforeInsert(v, ctx))) + const rows = await this.repository.insertMany(prepared, t) + return Promise.all(rows.map((row) => this.afterInsert(row, ctx))) + }) } findById(id: any, trx?: Driver): Promise { @@ -123,46 +124,58 @@ export abstract class EntityService { } async updateById(id: any, values: Record, trx?: Driver): Promise { - const ctx = { trx, id } - const prepared = await this.beforeUpdate(values, ctx) - const row = await this.repository.updateById(id, prepared, trx) - if (!row) return undefined - return this.afterUpdate(row, ctx) + return this.withTrx(trx, async (t) => { + const ctx = { trx: t, id } + const prepared = await this.beforeUpdate(values, ctx) + const row = await this.repository.updateById(id, prepared, t) + if (!row) return undefined + return this.afterUpdate(row, ctx) + }) } async updateByIdOrThrow(id: any, values: Record, trx?: Driver): Promise { - const ctx = { trx, id } - const prepared = await this.beforeUpdate(values, ctx) - const row = await this.repository.updateByIdOrThrow(id, prepared, trx) - return this.afterUpdate(row, ctx) + return this.withTrx(trx, async (t) => { + const ctx = { trx: t, id } + const prepared = await this.beforeUpdate(values, ctx) + const row = await this.repository.updateByIdOrThrow(id, prepared, t) + return this.afterUpdate(row, ctx) + }) } async update(where: Filter, values: Record, trx?: Driver): Promise { - const prepared = await this.beforeUpdate(values, { trx }) - return this.repository.update(where, prepared, trx) + return this.withTrx(trx, async (t) => { + const prepared = await this.beforeUpdate(values, { trx: t }) + return this.repository.update(where, prepared, t) + }) } async updateOne(where: Filter, values: Record, trx?: Driver): Promise { - const ctx = { trx } - const prepared = await this.beforeUpdate(values, ctx) - const row = await this.repository.updateOne(where, prepared, trx) - if (!row) return undefined - return this.afterUpdate(row, ctx) + return this.withTrx(trx, async (t) => { + const ctx = { trx: t } + const prepared = await this.beforeUpdate(values, ctx) + const row = await this.repository.updateOne(where, prepared, t) + if (!row) return undefined + return this.afterUpdate(row, ctx) + }) } async deleteById(id: any, trx?: Driver): Promise { - const ctx = { trx, id } - await this.beforeDelete(ctx) - const count = await this.repository.deleteById(id, trx) - await this.afterDelete(count, ctx) - return count + return this.withTrx(trx, async (t) => { + const ctx = { trx: t, id } + await this.beforeDelete(ctx) + const count = await this.repository.deleteById(id, t) + await this.afterDelete(count, ctx) + return count + }) } async deleteByIdOrThrow(id: any, trx?: Driver): Promise { - const ctx = { trx, id } - await this.beforeDelete(ctx) - await this.repository.deleteByIdOrThrow(id, trx) - await this.afterDelete(1, ctx) + return this.withTrx(trx, async (t) => { + const ctx = { trx: t, id } + await this.beforeDelete(ctx) + await this.repository.deleteByIdOrThrow(id, t) + await this.afterDelete(1, ctx) + }) } /** @@ -171,20 +184,24 @@ export abstract class EntityService { */ async deleteMany(ids: readonly any[], trx?: Driver): Promise { if (ids.length === 0) return 0 - const where = { [this.repository.primaryKey]: ids } as unknown as Filter - const ctx = { trx, where } - await this.beforeDelete(ctx) - const count = await this.repository.deleteMany(ids, trx) - await this.afterDelete(count, ctx) - return count + return this.withTrx(trx, async (t) => { + const where = { [this.repository.primaryKey]: ids } as unknown as Filter + const ctx = { trx: t, where } + await this.beforeDelete(ctx) + const count = await this.repository.deleteMany(ids, t) + await this.afterDelete(count, ctx) + return count + }) } async delete(where: Filter, trx?: Driver): Promise { - const ctx = { trx, where } - await this.beforeDelete(ctx) - const count = await this.repository.delete(where, trx) - await this.afterDelete(count, ctx) - return count + return this.withTrx(trx, async (t) => { + const ctx = { trx: t, where } + await this.beforeDelete(ctx) + const count = await this.repository.delete(where, t) + await this.afterDelete(count, ctx) + return count + }) } count(where?: Filter, trx?: Driver): Promise { diff --git a/node/src/HttpServer.ts b/node/src/HttpServer.ts index 858808ff..06eca425 100644 --- a/node/src/HttpServer.ts +++ b/node/src/HttpServer.ts @@ -18,6 +18,9 @@ export interface HttpServerOptions { prefix?: string cors?: FastifyCorsOptions | false trustProxy?: boolean + requestTimeoutMs?: number + connectionTimeoutMs?: number + keepAliveTimeoutMs?: number healthCheck?: HealthCheck logger?: Logger } @@ -58,7 +61,12 @@ export class HttpServer { } async init(): Promise { - this.server = fastify({ trustProxy: this.options.trustProxy ?? true }) + this.server = fastify({ + trustProxy: this.options.trustProxy ?? false, + requestTimeout: this.options.requestTimeoutMs ?? 30_000, + connectionTimeout: this.options.connectionTimeoutMs ?? 0, + keepAliveTimeout: this.options.keepAliveTimeoutMs ?? 72_000, + }) if (this.options.cors !== false) { const corsOptions: FastifyCorsOptions = this.options.cors ?? { diff --git a/node/src/ImageProcessor.ts b/node/src/ImageProcessor.ts index b1454c20..409726ab 100644 --- a/node/src/ImageProcessor.ts +++ b/node/src/ImageProcessor.ts @@ -1,7 +1,11 @@ import type { Sharp } from 'sharp' +import { promises as fs } from 'node:fs' import { ImageProcessorError } from './errors' import { loadSharp } from './internal/lazySharp' +/** Maximum allowed value for resize width or height. */ +export const MAX_DIMENSION = 65535 + export type ImageFormat = 'png' | 'jpeg' | 'webp' | 'avif' export interface ResizeOptions { @@ -38,6 +42,11 @@ export interface OptimizeOptions { stripMetadata?: boolean } +export interface ImageProcessorOptions { + /** Maximum allowed input pixels (width × height). Defaults to 50 000 000. */ + maxInputPixels?: number +} + export interface ImageMetadata { format: string width: number @@ -58,28 +67,35 @@ export class ImageProcessor { private readonly source: ImageSource private readonly ops: ReadonlyArray private readonly sourcePath: string | null + private readonly maxInputPixels: number | undefined - private constructor(source: ImageSource, ops: ReadonlyArray, sourcePath: string | null) { + private constructor(source: ImageSource, ops: ReadonlyArray, sourcePath: string | null, maxInputPixels: number | undefined) { this.source = source this.ops = ops this.sourcePath = sourcePath + this.maxInputPixels = maxInputPixels } - static fromBuffer(buffer: Buffer): ImageProcessor { + static fromBuffer(buffer: Buffer, options?: ImageProcessorOptions): ImageProcessor { if (!Buffer.isBuffer(buffer)) { throw new ImageProcessorError('invalid-buffer', 'fromBuffer requires a Buffer') } - return new ImageProcessor({ kind: 'buffer', data: buffer }, [], null) + return new ImageProcessor({ kind: 'buffer', data: buffer }, [], null, options?.maxInputPixels) } - static fromPath(path: string): ImageProcessor { + static fromPath(path: string, options?: ImageProcessorOptions): ImageProcessor { if (typeof path !== 'string' || path.length === 0) { throw new ImageProcessorError('invalid-path', 'fromPath requires a non-empty string') } - return new ImageProcessor({ kind: 'path', path }, [], path) + return new ImageProcessor({ kind: 'path', path }, [], path, options?.maxInputPixels) } resize(options: ResizeOptions): ImageProcessor { + for (const dim of [options.width, options.height]) { + if (dim !== undefined && (!Number.isInteger(dim) || dim <= 0 || dim > MAX_DIMENSION)) { + throw new ImageProcessorError('resize-invalid', 'resize width/height must be positive integers within bounds', this.sourcePath ?? undefined) + } + } return this.append(pipeline => pipeline.resize({ width: options.width, height: options.height, @@ -133,10 +149,7 @@ export class ImageProcessor { pipeline.avif({ quality, effort }) break case undefined: - pipeline.jpeg({ mozjpeg: true, quality, progressive: true }) - pipeline.png({ palette, compressionLevel: 9, quality }) - pipeline.webp({ quality, effort }) - pipeline.avif({ quality, effort }) + // No encoder — sharp will use the input format unchanged. break } if (!stripMetadata) { @@ -176,31 +189,34 @@ export class ImageProcessor { if (typeof path !== 'string' || path.length === 0) { throw new ImageProcessorError('invalid-path', 'toFile requires a non-empty string') } + const tmp = `${path}.${process.pid}.tmp` try { const pipeline = await this.materialize() - const info = await pipeline.toFile(path) + const info = await pipeline.toFile(tmp) + await fs.rename(tmp, path) return { format: info.format ?? 'unknown', width: info.width, height: info.height, channels: info.channels, - hasAlpha: info.premultiplied ?? false, + hasAlpha: info.channels === 4 || info.channels === 2, size: info.size } } catch (error) { + await fs.unlink(tmp).catch(() => undefined) throw ImageProcessor.wrapError(error, 'write-failed', path) } } private append(op: PipelineOp): ImageProcessor { const next = this.ops.length === 0 ? [op] : [...this.ops, op] - return new ImageProcessor(this.source, next, this.sourcePath) + return new ImageProcessor(this.source, next, this.sourcePath, this.maxInputPixels) } private async materialize(): Promise { const sharp = await loadSharp() const input = this.source.kind === 'buffer' ? this.source.data : this.source.path - let pipeline: Sharp = sharp(input) + let pipeline: Sharp = sharp(input, { limitInputPixels: this.maxInputPixels ?? 50_000_000, failOn: 'error' }) for (const op of this.ops) { pipeline = op(pipeline) } diff --git a/node/src/RESTRouteHandler.ts b/node/src/RESTRouteHandler.ts index 699db8aa..1ded6382 100644 --- a/node/src/RESTRouteHandler.ts +++ b/node/src/RESTRouteHandler.ts @@ -56,8 +56,8 @@ export class RESTRouteHandler extends RouteHandler(options.methods ?? ALL_METHODS) - this.filterableFields = resolveFieldList(options.filterableFields, options.schema) - this.sortableFields = resolveFieldList(options.sortableFields, options.schema) + this.filterableFields = resolveFieldList(options.filterableFields, options.schema, 'filter') + this.sortableFields = resolveFieldList(options.sortableFields, options.schema, 'sort') } register(fastify: FastifyInstance): void { @@ -96,10 +96,11 @@ export class RESTRouteHandler extends RouteHandler => { const opts = this.options as RESTRouteHandlerOptions const raw = (req.query ?? {}) as Record - const { offset, limit } = normalizeOffsetLimit(raw, opts.pagination) - const sort = parseSort(raw, { allowedFields: this.sortableFields }) + const query = this.sanitizeQuery(raw, req) as Record + const { offset, limit } = normalizeOffsetLimit(query, opts.pagination) + const sort = parseSort(query, { allowedFields: this.sortableFields }) const orderBy = sort ?? opts.defaultOrderBy - const where = parseFilters(raw, { + const where = parseFilters(query, { allowedFields: this.filterableFields, schema: opts.schema, }) @@ -191,6 +192,7 @@ function groupFor( function resolveFieldList( explicit: ReadonlyArray | undefined, schema: { [K in keyof T]?: FieldRule } | undefined, + kind: 'filter' | 'sort', ): ReadonlyArray | undefined { if (explicit !== undefined) return explicit if (!schema) return [] as ReadonlyArray @@ -198,6 +200,8 @@ function resolveFieldList( for (const key of Object.keys(schema) as (keyof T & string)[]) { const rule = schema[key] if (rule?.private || rule?.writeOnly) continue + if (kind === 'filter' && !rule?.filterable) continue + if (kind === 'sort' && !rule?.sortable) continue out.push(key) } return out diff --git a/node/src/RouteHandler.ts b/node/src/RouteHandler.ts index 17c9652b..89a57b52 100644 --- a/node/src/RouteHandler.ts +++ b/node/src/RouteHandler.ts @@ -150,7 +150,7 @@ export abstract class RouteHandler> { return this.idParser(raw) as ID } catch (error) { if (error instanceof ValidationError) throw error - throw new ValidationError(`Invalid ${this.idParam}: ${raw}`, String(error)) + throw new ValidationError(`Invalid ${this.idParam}: ${raw}`) } } diff --git a/node/src/env.ts b/node/src/env.ts index 42fb77c5..07293152 100644 --- a/node/src/env.ts +++ b/node/src/env.ts @@ -18,7 +18,9 @@ function env( if (type === 'number') { if (value === undefined) return defaultValue as number | null const numberValue = parseInt(value, 10) - return numberValue.toString() === value ? numberValue : (defaultValue as number | null) + if (numberValue.toString() === value) return numberValue + console.warn(`[env] ${key}="${value}" is not a valid integer; using default`) + return defaultValue as number | null } if (type === 'boolean') { const boolValue = (value + '').toLowerCase() diff --git a/node/src/internal/lazySharp.ts b/node/src/internal/lazySharp.ts index 995208df..b415e3fe 100644 --- a/node/src/internal/lazySharp.ts +++ b/node/src/internal/lazySharp.ts @@ -15,7 +15,8 @@ export async function loadSharp(): Promise { return factory }).catch(error => { pending = null - throw error + const cause = error instanceof Error ? error.message : String(error) + throw new Error(`sharp is not installed — add it as an optional dependency: npm install sharp. Original error: ${cause}`) }) return pending } diff --git a/node/src/kv/KVService.ts b/node/src/kv/KVService.ts index 82a69dbc..31a7fd02 100644 --- a/node/src/kv/KVService.ts +++ b/node/src/kv/KVService.ts @@ -20,6 +20,7 @@ import { Versioned } from './Versioned' const SCAN_BATCH_DEFAULT = 100 const DEL_BATCH_LIMIT = 500 +const POP_N_MAX = 1000 export interface DelByPatternOptions { confirm?: boolean @@ -77,6 +78,7 @@ export class KVService { this.serializer, this.subscribers, bufferClient, + this.locker, ) this.versioned = new Versioned(this.client, this.keys, this.scripts, this.serializer, bufferClient) } @@ -130,10 +132,10 @@ export class KVService { async warmScripts(): Promise { const names = Object.keys(KV_LUA_SCRIPTS) as (keyof typeof KV_LUA_SCRIPTS)[] await Promise.all(names.map(async (name) => { - const sha = this.scripts.get(name).sha + this.scripts.get(name) await (this.client as unknown as { scriptLoad: (source: string) => Promise - }).scriptLoad(KV_LUA_SCRIPTS[name]).catch(() => sha) + }).scriptLoad(KV_LUA_SCRIPTS[name]) })) } @@ -378,7 +380,7 @@ export class KVService { async popN(key: string, count: number): Promise { const reply = (await this.scripts.get('popN').run(this.client, { keys: [this.key(key)], - arguments: [String(count)], + arguments: [String(Math.min(count, POP_N_MAX))], })) as unknown if (!Array.isArray(reply)) return [] return reply.map((v) => String(v)) @@ -570,7 +572,7 @@ export class KVService { return this.locker.extendLock(key, token, ttlMs) } - addScore(boardKey: string, member: string, score: number): Promise { + addScore(boardKey: string, member: string, score: number): Promise { return this.leaderboard.addScore(boardKey, member, score) } diff --git a/node/src/kv/Leaderboard.ts b/node/src/kv/Leaderboard.ts index a008b95f..e5be4f27 100644 --- a/node/src/kv/Leaderboard.ts +++ b/node/src/kv/Leaderboard.ts @@ -22,12 +22,13 @@ export class Leaderboard { this.direction = options.direction ?? 'desc' } - addScore(boardKey: string, member: string, score: number): Promise { - return this.client.zAdd(this.keys.build(boardKey), { score, value: member }) + async addScore(boardKey: string, member: string, score: number): Promise { + await this.client.zAdd(this.keys.build(boardKey), { score, value: member }) } - incrScore(boardKey: string, member: string, delta: number): Promise { - return this.client.zIncrBy(this.keys.build(boardKey), delta, member) as unknown as Promise + async incrScore(boardKey: string, member: string, delta: number): Promise { + const reply = await this.client.zIncrBy(this.keys.build(boardKey), delta, member) + return Number(reply) } async addScoreAndRank( @@ -37,7 +38,7 @@ export class Leaderboard { ): Promise<{ rank: number | null; score: number }> { const reply = (await this.scripts.get('addScoreAndRank').run(this.client, { keys: [this.keys.build(boardKey)], - arguments: [String(score), member], + arguments: [String(score), member, this.direction], })) as [number | string, number | string] const rank = Number(reply[0]) return { diff --git a/node/src/kv/RateLimiter.ts b/node/src/kv/RateLimiter.ts index cd17afe8..4ce3b201 100644 --- a/node/src/kv/RateLimiter.ts +++ b/node/src/kv/RateLimiter.ts @@ -62,6 +62,7 @@ export class RateLimiter { refillPerSecond: number, cost = 1, ): Promise<{ allowed: boolean; tokens: number }> { + if (refillPerSecond < 0) throw new RangeError('refillPerSecond must be >= 0') const now = Date.now() const reply = (await this.scripts.get('tokenBucket').run(this.client, { keys: [this.keys.build(BUCKET_NAMESPACE, key)], diff --git a/node/src/kv/SubscriberPool.ts b/node/src/kv/SubscriberPool.ts index 05f0e7a2..edf85712 100644 --- a/node/src/kv/SubscriberPool.ts +++ b/node/src/kv/SubscriberPool.ts @@ -22,17 +22,30 @@ export class SubscriberPool { private readonly entries = new Map() private connection: RedisClient | null = null private connecting: Promise | null = null + private lockTail: Promise = Promise.resolve() + private pendingSubscribes = 0 constructor( private readonly duplicate: () => RedisClient, private readonly onError?: SubscriberErrorHook, ) {} + private withLock(fn: () => Promise): Promise { + const current = this.lockTail + let resolve!: () => void + this.lockTail = new Promise(r => (resolve = r)) + return current.then(() => fn()).finally(resolve) as Promise + } + private async ensureConnection(): Promise { - if (this.connection) return this.connection + if (this.connection?.isOpen) return this.connection + if (this.connection && !this.connection.isOpen) { + this.connection = null + } if (this.connecting) return this.connecting this.connecting = (async () => { const client = this.duplicate() + client.on('ready', () => this.resubscribeAll(client)) await client.connect() this.connection = client return client @@ -44,52 +57,70 @@ export class SubscriberPool { } } + private resubscribeAll(conn: RedisClient): void { + const sub = conn as unknown as SubscriberCapableClient + for (const [channel, entry] of this.entries) { + sub.subscribe(channel, entry.rootListener, true).catch(() => {}) + } + } + async subscribeRaw(channel: string, handler: RawHandler): Promise { - const client = await this.ensureConnection() - let entry = this.entries.get(channel) - if (!entry) { - const handlers = new Set() - const onError = this.onError - const rootListener: RawHandler = (message, channelBuf) => { - for (const h of handlers) { - try { - h(message, channelBuf) - } catch (error) { - if (onError) onError(error, channel) + this.pendingSubscribes++ + try { + const client = await this.ensureConnection() + await this.withLock(async () => { + let entry = this.entries.get(channel) + if (!entry) { + const handlers = new Set() + const onError = this.onError + const rootListener: RawHandler = (message, channelBuf) => { + for (const h of handlers) { + try { + h(message, channelBuf) + } catch (error) { + if (onError) onError(error, channel) + } + } } + entry = { handlers, rootListener } + this.entries.set(channel, entry) + await (client as unknown as SubscriberCapableClient).subscribe( + channel, + rootListener, + true, + ) } - } - entry = { handlers, rootListener } - this.entries.set(channel, entry) - await (client as unknown as SubscriberCapableClient).subscribe( - channel, - rootListener, - true, - ) + entry.handlers.add(handler) + }) + } finally { + this.pendingSubscribes-- } - entry.handlers.add(handler) let removed = false return { close: async () => { if (removed) return removed = true - const current = this.entries.get(channel) - if (!current) return - current.handlers.delete(handler) - if (current.handlers.size === 0) { - this.entries.delete(channel) - await (client as unknown as SubscriberCapableClient) - .unsubscribe(channel) - .catch(() => {}) - } - if (this.entries.size === 0 && this.connection) { - const conn = this.connection - this.connection = null - if (conn.isOpen) { - await conn.quit().catch(() => {}) + await this.withLock(async () => { + const current = this.entries.get(channel) + if (!current) return + current.handlers.delete(handler) + if (current.handlers.size === 0) { + this.entries.delete(channel) + if (this.connection) { + await (this.connection as unknown as SubscriberCapableClient) + .unsubscribe(channel) + .catch(() => {}) + } } - } + if (this.entries.size === 0 && this.pendingSubscribes === 0 && this.connection) { + const conn = this.connection + this.connection = null + if (conn.isOpen) { + await conn.quit().catch(() => {}) + } + } + }) }, } } diff --git a/node/src/kv/ValueStore.ts b/node/src/kv/ValueStore.ts index 431c56e7..a7d8ff21 100644 --- a/node/src/kv/ValueStore.ts +++ b/node/src/kv/ValueStore.ts @@ -2,6 +2,7 @@ import type Serializer from '@toolcase/serializer' import { RESP_TYPES } from 'redis' import { KVServiceError } from '../errors' import { KeyBuilder } from './keys' +import type { Locker } from './Locker' import { LuaScriptCache } from './scripts' import type { SubscriberPool } from './SubscriberPool' import type { RedisClient, SubscribeHandler, Subscription } from './types' @@ -28,6 +29,7 @@ export class ValueStore { private readonly serializer: Serializer | undefined, private readonly subscribers?: SubscriberPool, bufferClient?: unknown, + private readonly locker?: Locker, ) { if (serializer !== undefined) { const s = serializer as unknown as { encode?: unknown; decode?: unknown } @@ -135,9 +137,22 @@ export class ValueStore { ): Promise { const cached = await this.getValue(type, key) if (cached !== null) return cached - const value = await factory() - await this.setValue(type, key, value, { EX: ttlSeconds }) - return value + if (!this.locker) { + const value = await factory() + await this.setValue(type, key, value, { EX: ttlSeconds }) + return value + } + return this.locker.withLock( + this.keys.build('remember', type, key), + 30_000, + async (_handle) => { + const again = await this.getValue(type, key) + if (again !== null) return again + const value = await factory() + await this.setValue(type, key, value, { EX: ttlSeconds }) + return value + }, + ) } enqueueValue(type: string, queueKey: string, message: object): Promise { diff --git a/node/src/kv/Versioned.ts b/node/src/kv/Versioned.ts index 98e3474a..d89338b1 100644 --- a/node/src/kv/Versioned.ts +++ b/node/src/kv/Versioned.ts @@ -59,7 +59,14 @@ export class Versioned { ttlSeconds ? String(ttlSeconds) : '', ], })) as [number, number] - return { ok: Number(reply[0]) === 1, version: Number(reply[1]) } + const ok = Number(reply[0]) + const ver = Number(reply[1]) + if (ok === 0 && ver === -1) { + throw new KVServiceError( + `versionedSet: key '${key}' already exists as a foreign hash (missing __vset marker)`, + ) + } + return { ok: ok === 1, version: ver } } async setValue( diff --git a/node/src/kv/keys.ts b/node/src/kv/keys.ts index 6cc56a6a..f478cf29 100644 --- a/node/src/kv/keys.ts +++ b/node/src/kv/keys.ts @@ -7,25 +7,33 @@ export class KeyBuilder { public readonly separator: string, ) {} + private encodePart(p: string | number): string { + const s = typeof p === 'string' ? p : String(p) + if (s.includes(this.separator)) { + return s.split(this.separator).join(encodeURIComponent(this.separator)) + } + return s + } + build(part: string | number): string build(...parts: (string | number)[]): string build(first: string | number, ...rest: (string | number)[]): string { if (rest.length === 0) { - const s = typeof first === 'string' ? first : String(first) + const s = this.encodePart(first) return this.namespace.length === 0 ? s : `${this.namespace}${this.separator}${s}` } - let joined = typeof first === 'string' ? first : String(first) + let joined = this.encodePart(first) for (let i = 0; i < rest.length; i++) { - const p = rest[i] - joined = `${joined}${this.separator}${typeof p === 'string' ? p : String(p)}` + joined = `${joined}${this.separator}${this.encodePart(rest[i])}` } return this.namespace.length === 0 ? joined : `${this.namespace}${this.separator}${joined}` } scope(namespace: string): KeyBuilder { + const encodedNamespace = this.encodePart(namespace) const next = this.namespace.length === 0 - ? namespace - : `${this.namespace}${this.separator}${namespace}` + ? encodedNamespace + : `${this.namespace}${this.separator}${encodedNamespace}` return new KeyBuilder(next, this.separator) } diff --git a/node/src/kv/scripts.ts b/node/src/kv/scripts.ts index 737b0acf..9e6e629c 100644 --- a/node/src/kv/scripts.ts +++ b/node/src/kv/scripts.ts @@ -40,6 +40,7 @@ if count < limit then redis.call("PEXPIRE", KEYS[1], window) return {1, count + 1, limit - count - 1} end +redis.call("PEXPIRE", KEYS[1], window) return {0, count, 0} `.trim() @@ -62,13 +63,15 @@ if tokens >= cost then allowed = 1 end redis.call("HSET", key, "tokens", tostring(tokens), "last", tostring(now)) -local ttl = math.ceil(capacity / refill) + 1 +local ttl +if refill > 0 then ttl = math.ceil(capacity / refill) + 1 else ttl = math.ceil(capacity) + 1 end redis.call("EXPIRE", key, ttl) return {allowed, tostring(tokens)} `.trim() const LUA_INCR_CAPPED = ` local current = tonumber(redis.call("GET", KEYS[1])) +local is_new = current == nil if current == nil then current = 0 end local delta = tonumber(ARGV[1]) local cap = tonumber(ARGV[2]) @@ -76,7 +79,7 @@ if current + delta > cap then return {0, current} end local next = redis.call("INCRBY", KEYS[1], delta) -if ARGV[3] ~= "" then +if is_new and ARGV[3] ~= "" then redis.call("EXPIRE", KEYS[1], tonumber(ARGV[3])) end return {1, next} @@ -91,7 +94,12 @@ return v `.trim() const LUA_VERSIONED_SET = ` -local cur = redis.call("HGET", KEYS[1], "version") +local fields = redis.call("HMGET", KEYS[1], "version", "__vset") +local cur = fields[1] +local marker = fields[2] +if not cur and not marker and redis.call("EXISTS", KEYS[1]) == 1 then + return {0, -1} +end if cur and tonumber(cur) ~= tonumber(ARGV[1]) then return {0, tonumber(cur)} end @@ -99,7 +107,7 @@ if not cur and tonumber(ARGV[1]) ~= 0 then return {0, 0} end local nextVersion = tonumber(ARGV[1]) + 1 -redis.call("HSET", KEYS[1], "version", tostring(nextVersion), "data", ARGV[2]) +redis.call("HSET", KEYS[1], "version", tostring(nextVersion), "data", ARGV[2], "__vset", "1") if ARGV[3] ~= "" then redis.call("EXPIRE", KEYS[1], tonumber(ARGV[3])) end @@ -108,7 +116,9 @@ return {1, nextVersion} const LUA_ADD_SCORE_RANK = ` redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) -local rank = redis.call("ZREVRANK", KEYS[1], ARGV[2]) +local rank +if ARGV[3] == "asc" then rank = redis.call("ZRANK", KEYS[1], ARGV[2]) +else rank = redis.call("ZREVRANK", KEYS[1], ARGV[2]) end local score = redis.call("ZSCORE", KEYS[1], ARGV[2]) if rank == false then rank = -1 end if score == false then score = "0" end @@ -117,7 +127,7 @@ return {rank, score} const LUA_POP_N = ` local results = {} -local count = tonumber(ARGV[1]) +local count = math.min(tonumber(ARGV[1]), 1000) for i = 1, count do local v = redis.call("LPOP", KEYS[1]) if not v then break end @@ -143,7 +153,9 @@ if v == 1 then else ttl = redis.call("TTL", KEYS[1]) if ttl < 0 then + redis.call("SET", KEYS[1], "1") redis.call("EXPIRE", KEYS[1], tonumber(ARGV[1])) + v = 1 ttl = tonumber(ARGV[1]) end end diff --git a/node/src/main.ts b/node/src/main.ts index f6ee91b0..1b062fdc 100644 --- a/node/src/main.ts +++ b/node/src/main.ts @@ -16,3 +16,4 @@ export * from './ImageProcessor' export * from './AtlasBuilder' export * from './kv/index' export * from './oauth2/index' +export * from './store/index' diff --git a/node/src/oauth2/callback.ts b/node/src/oauth2/callback.ts new file mode 100644 index 00000000..bd0bd860 --- /dev/null +++ b/node/src/oauth2/callback.ts @@ -0,0 +1,23 @@ +import { timingSafeEqual } from 'node:crypto' +import { OAuth2CallbackError } from '../errors' + +export interface VerifyCallbackInput { + stored: string + received: string +} + +/** + * Constant-time comparison of CSRF `state` values returned by the authorization server. + * + * IMPORTANT: always pass `ctx.nonce` through to `verifyIdToken` as well — omitting it + * silently skips the nonce check and leaves the flow vulnerable to replay attacks. + * + * @throws {OAuth2CallbackError} when lengths differ or values do not match + */ +export function verifyCallback(input: VerifyCallbackInput): void { + const a = Buffer.from(input.stored) + const b = Buffer.from(input.received) + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new OAuth2CallbackError('state mismatch') + } +} diff --git a/node/src/oauth2/flow.ts b/node/src/oauth2/flow.ts index 59754b48..c1e5d27b 100644 --- a/node/src/oauth2/flow.ts +++ b/node/src/oauth2/flow.ts @@ -1,7 +1,7 @@ import { OAuth2TokenError, OAuth2ProtocolError } from '../errors' import { fetchWithOptions, type HttpOptions } from '../http/options' import type { OAuth2ProviderConfig, OAuth2Tokens } from './types' -import { postForm, parseTokens } from './wire' +import { postForm, parseTokens, safeUpstream } from './wire' export interface BuildAuthorizeURLInput { state: string @@ -76,7 +76,7 @@ export async function exchangeCode(provider: OAuth2ProviderConfig, input: Exchan if (input.codeVerifier) body.set('code_verifier', input.codeVerifier) const { status, body: parsed } = await postForm({ endpoint: provider.tokenEndpoint, body, provider }, opts) if (status < 200 || status >= 300) { - throw new OAuth2TokenError(status, describeError(parsed) || `token exchange failed (${status})`, parsed) + throw new OAuth2TokenError(status, describeError(parsed) || `token exchange failed (${status})`, safeUpstream(parsed)) } return parseTokens(parsed) } @@ -88,7 +88,7 @@ export async function refreshToken(provider: OAuth2ProviderConfig, input: Refres if (input.scope && input.scope.length > 0) body.set('scope', input.scope.join(' ')) const { status, body: parsed } = await postForm({ endpoint: provider.tokenEndpoint, body, provider }, opts) if (status < 200 || status >= 300) { - throw new OAuth2TokenError(status, describeError(parsed) || `refresh failed (${status})`, parsed) + throw new OAuth2TokenError(status, describeError(parsed) || `refresh failed (${status})`, safeUpstream(parsed)) } return parseTokens(parsed) } @@ -102,7 +102,7 @@ export async function revokeToken(provider: OAuth2ProviderConfig, token: string, if (hint) body.set('token_type_hint', hint) const { status, body: parsed } = await postForm({ endpoint: provider.revocationEndpoint, body, provider }, opts) if (status < 200 || status >= 300) { - throw new OAuth2TokenError(status, describeError(parsed) || `revoke failed (${status})`, parsed) + throw new OAuth2TokenError(status, describeError(parsed) || `revoke failed (${status})`, safeUpstream(parsed)) } } @@ -127,7 +127,7 @@ export async function fetchUserinfo(provider: OAuth2ProviderConfig, accessToken: throw new OAuth2ProtocolError('userinfo response is not valid JSON') } if (response.status < 200 || response.status >= 300) { - throw new OAuth2TokenError(response.status, describeError(parsed) || `userinfo failed (${response.status})`, parsed) + throw new OAuth2TokenError(response.status, describeError(parsed) || `userinfo failed (${response.status})`, safeUpstream(parsed)) } if (parsed === null || typeof parsed !== 'object') { throw new OAuth2ProtocolError('userinfo response is not a JSON object') diff --git a/node/src/oauth2/grants.ts b/node/src/oauth2/grants.ts index dcc72536..ec59104f 100644 --- a/node/src/oauth2/grants.ts +++ b/node/src/oauth2/grants.ts @@ -2,7 +2,7 @@ import { EventEmitter } from '@toolcase/base' import { OAuth2TokenError, OAuth2ProtocolError } from '../errors' import type { HttpOptions } from '../http/options' import type { OAuth2ProviderConfig, OAuth2Tokens } from './types' -import { postForm, parseTokens } from './wire' +import { postForm, parseTokens, safeUpstream } from './wire' export interface ClientCredentialsInput { scope?: readonly string[] @@ -20,7 +20,7 @@ export async function clientCredentialsToken(provider: OAuth2ProviderConfig, inp if (input.extraParams) for (const [k, v] of Object.entries(input.extraParams)) body.set(k, v) const { status, body: parsed } = await postForm({ endpoint: provider.tokenEndpoint, body, provider }, opts) if (status < 200 || status >= 300) { - throw new OAuth2TokenError(status, describeError(parsed) || `client_credentials failed (${status})`, parsed) + throw new OAuth2TokenError(status, describeError(parsed) || `client_credentials failed (${status})`, safeUpstream(parsed)) } return parseTokens(parsed) } @@ -44,7 +44,7 @@ export async function requestDeviceCode(provider: OAuth2ProviderConfig, input: { if (input.extraParams) for (const [k, v] of Object.entries(input.extraParams)) body.set(k, v) const { status, body: parsed } = await postForm({ endpoint: provider.deviceAuthorizationEndpoint, body, provider }, opts) if (status < 200 || status >= 300) { - throw new OAuth2TokenError(status, describeError(parsed) || `device authorization failed (${status})`, parsed) + throw new OAuth2TokenError(status, describeError(parsed) || `device authorization failed (${status})`, safeUpstream(parsed)) } if (!parsed || typeof parsed !== 'object') { throw new OAuth2ProtocolError('device authorization response is not a JSON object') @@ -106,7 +106,7 @@ export async function pollDeviceToken(provider: OAuth2ProviderConfig, input: Pol events?.emit('interval_changed', { type: 'interval_changed', intervalSeconds: interval } as DevicePollEvent) continue } - throw new OAuth2TokenError(status, describeError(parsed) || `device token failed (${status})`, parsed) + throw new OAuth2TokenError(status, describeError(parsed) || `device token failed (${status})`, safeUpstream(parsed)) } } diff --git a/node/src/oauth2/index.ts b/node/src/oauth2/index.ts index c35786f1..9a39ce19 100644 --- a/node/src/oauth2/index.ts +++ b/node/src/oauth2/index.ts @@ -1,6 +1,7 @@ export * from './types' export * from './random' export * from './flow' +export * from './callback' export * from './grants' export * from './resource' export * from './oidc' diff --git a/node/src/oauth2/oidc.ts b/node/src/oauth2/oidc.ts index 25a704f1..2a38c6d1 100644 --- a/node/src/oauth2/oidc.ts +++ b/node/src/oauth2/oidc.ts @@ -1,5 +1,5 @@ import { Cache } from '@toolcase/base' -import { createHash } from 'node:crypto' +import { createHash, timingSafeEqual } from 'node:crypto' import { OIDCVerificationError, OAuth2ProtocolError } from '../errors' import { fetchWithOptions, type HttpOptions } from '../http/options' import type { OAuth2ProviderConfig, ClientAuthMethod } from './types' @@ -36,9 +36,7 @@ export interface OIDCDiscoveryDocument { const DISCOVERY_TTL_MS = 24 * 60 * 60 * 1000 -let discoveryFetchOpts: HttpOptions = {} - -let discoveryCache = new Cache(async (issuer: string) => fetchDiscovery(issuer, discoveryFetchOpts), DISCOVERY_TTL_MS) +let discoveryCache = new Cache((issuer: string) => fetchDiscovery(issuer, {}), DISCOVERY_TTL_MS) async function fetchDiscovery(issuer: string, opts: HttpOptions): Promise { const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer @@ -64,16 +62,22 @@ async function fetchDiscovery(issuer: string, opts: HttpOptions): Promise { - if (opts.cacheTtlMs === 0) { - return fetchDiscovery(issuer, opts) + const { cacheTtlMs, ...fetchOpts } = opts + // Bypass the shared cache when caller supplies a custom fetchImpl or headers — these + // are caller-specific and cannot be keyed into a shared cache without a race or + // confused-deputy / SSRF hazard. + if (cacheTtlMs === 0 || fetchOpts.fetchImpl !== undefined || fetchOpts.headers !== undefined) { + return fetchDiscovery(issuer, fetchOpts) } - discoveryFetchOpts = opts - if (typeof opts.cacheTtlMs === 'number' && opts.cacheTtlMs > 0) { - discoveryCache.setMS(opts.cacheTtlMs) + if (typeof cacheTtlMs === 'number' && cacheTtlMs > 0) { + discoveryCache.setMS(cacheTtlMs) } const result = await discoveryCache.get(issuer) if (!result) throw new OAuth2ProtocolError('discovery returned empty') @@ -84,24 +88,27 @@ export function clearDiscoveryCache(issuer?: string): void { if (issuer) { discoveryCache.invalidate(issuer) } else { - discoveryCache = new Cache(async (i: string) => fetchDiscovery(i, discoveryFetchOpts), DISCOVERY_TTL_MS) + discoveryCache = new Cache((i: string) => fetchDiscovery(i, {}), DISCOVERY_TTL_MS) } } const DEFAULT_JWKS_TTL_MS = 600_000 -let jwksCache = new Cache(async (jwksUri: string) => createJwksGetter(jwksUri), DEFAULT_JWKS_TTL_MS) +let jwksCache = new Cache(async (jwksUri: string) => createJwksGetter(jwksUri, {}), DEFAULT_JWKS_TTL_MS) -async function createJwksGetter(jwksUri: string): Promise { +async function createJwksGetter(jwksUri: string, opts: HttpOptions): Promise { const j = await loadJose() - return j.createRemoteJWKSet(new URL(jwksUri)) + return j.createRemoteJWKSet(new URL(jwksUri), { + [j.customFetch]: opts.fetchImpl, + timeoutDuration: opts.timeoutMs, + }) } export function clearJwksCache(jwksUri?: string): void { if (jwksUri) { jwksCache.invalidate(jwksUri) } else { - jwksCache = new Cache(async (uri: string) => createJwksGetter(uri), DEFAULT_JWKS_TTL_MS) + jwksCache = new Cache(async (uri: string) => createJwksGetter(uri, {}), DEFAULT_JWKS_TTL_MS) } } @@ -113,6 +120,7 @@ export interface VerifyIdTokenOptions { clockToleranceSeconds?: number jwksCacheMs?: number allowedAlgorithms?: readonly string[] + http?: HttpOptions } export interface OIDCVerifyContext { @@ -152,13 +160,24 @@ export async function verifyIdToken(idToken: string, options: VerifyIdTokenOptio if (!options.jwksUri) { throw new OIDCVerificationError('verifyIdToken: jwksUri or jwks is required') } - if (typeof options.jwksCacheMs === 'number' && options.jwksCacheMs > 0) { - jwksCache.setMS(options.jwksCacheMs) + const httpOpts = options.http ?? {} + // Bypass the shared cache when caller supplies a custom fetchImpl — per-call + // fetch impls cannot be keyed into a shared cache without SSRF / confused-deputy hazard. + if (httpOpts.fetchImpl !== undefined) { + jwks = await createJwksGetter(options.jwksUri, httpOpts) + } else { + if (typeof options.jwksCacheMs === 'number' && options.jwksCacheMs > 0) { + jwksCache.setMS(options.jwksCacheMs) + } + jwks = await jwksCache.get(options.jwksUri) + if (!jwks) throw new OIDCVerificationError('jwks fetch failed') } - jwks = await jwksCache.get(options.jwksUri) - if (!jwks) throw new OIDCVerificationError('jwks fetch failed') } const algorithms = options.allowedAlgorithms ? [...options.allowedAlgorithms] : [...DEFAULT_ALG_LIST] + const SYM = /^(HS\d{3}|none)$/i + if (algorithms.some(a => SYM.test(a))) { + throw new OIDCVerificationError('symmetric/none algorithms are not allowed for ID tokens') + } let verifyResult: { payload: any; protectedHeader: any } try { verifyResult = await j.jwtVerify(idToken, jwks as any, { @@ -172,7 +191,7 @@ export async function verifyIdToken(idToken: string, options: VerifyIdTokenOptio } const { payload, protectedHeader } = verifyResult if (ctx.nonce !== undefined) { - if (payload.nonce !== ctx.nonce) { + if (typeof payload.nonce !== 'string' || !timingSafeStringEqual(payload.nonce, ctx.nonce)) { throw new OIDCVerificationError('nonce mismatch') } } @@ -198,17 +217,13 @@ export async function verifyIdToken(idToken: string, options: VerifyIdTokenOptio } } } - if (ctx.accessToken !== undefined && typeof payload.at_hash === 'string') { - const expected = computeHalfHash(ctx.accessToken, protectedHeader.alg) - if (expected !== payload.at_hash) { - throw new OIDCVerificationError('at_hash mismatch') - } + if (ctx.accessToken !== undefined) { + if (typeof payload.at_hash !== 'string') throw new OIDCVerificationError('at_hash required but absent') + if (!timingSafeStringEqual(computeHalfHash(ctx.accessToken, protectedHeader.alg), payload.at_hash)) throw new OIDCVerificationError('at_hash mismatch') } - if (ctx.authorizationCode !== undefined && typeof payload.c_hash === 'string') { - const expected = computeHalfHash(ctx.authorizationCode, protectedHeader.alg) - if (expected !== payload.c_hash) { - throw new OIDCVerificationError('c_hash mismatch') - } + if (ctx.authorizationCode !== undefined) { + if (typeof payload.c_hash !== 'string') throw new OIDCVerificationError('c_hash required but absent') + if (!timingSafeStringEqual(computeHalfHash(ctx.authorizationCode, protectedHeader.alg), payload.c_hash)) throw new OIDCVerificationError('c_hash mismatch') } return { header: { alg: protectedHeader.alg, kid: protectedHeader.kid, typ: protectedHeader.typ }, @@ -217,19 +232,33 @@ export async function verifyIdToken(idToken: string, options: VerifyIdTokenOptio } } +// Maps JWT algorithm identifiers to the hash used for at_hash/c_hash per RFC 7519 §3. +// EdDSA maps to sha512 (Ed25519 convention, RFC 8037 §2.4). +// Ed448 uses SHAKE-256 internally, which has no standardised at_hash mapping — +// tokens signed with Ed448 will be rejected by this function (fail-closed). +const ALG_HASH_MAP: Readonly> = { + RS256: 'sha256', ES256: 'sha256', PS256: 'sha256', + RS384: 'sha384', ES384: 'sha384', PS384: 'sha384', + RS512: 'sha512', ES512: 'sha512', PS512: 'sha512', + EdDSA: 'sha512' +} + function computeHalfHash(value: string, alg: string): string { - const algoMap: Record = { - RS256: 'sha256', ES256: 'sha256', PS256: 'sha256', HS256: 'sha256', - RS384: 'sha384', ES384: 'sha384', PS384: 'sha384', HS384: 'sha384', - RS512: 'sha512', ES512: 'sha512', PS512: 'sha512', HS512: 'sha512', - EdDSA: 'sha512' - } - const algo = algoMap[alg] ?? 'sha256' + const algo = ALG_HASH_MAP[alg] + if (!algo) throw new OIDCVerificationError(`computeHalfHash: unsupported algorithm for at_hash/c_hash: ${alg}`) const digest = createHash(algo).update(value).digest() const half = digest.subarray(0, digest.length / 2) return half.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } +function timingSafeStringEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a, 'utf8') + const bufB = Buffer.from(b, 'utf8') + // Length mismatch means definite inequality; length is not secret for hash outputs or nonces. + if (bufA.length !== bufB.length) return false + return timingSafeEqual(bufA, bufB) +} + export interface OidcProviderInput { issuer: string clientId: string diff --git a/node/src/oauth2/profiles.ts b/node/src/oauth2/profiles.ts index 826ed933..cf6f224d 100644 --- a/node/src/oauth2/profiles.ts +++ b/node/src/oauth2/profiles.ts @@ -1,12 +1,17 @@ -import type { OAuth2Profile, OAuth2Tokens } from './types' +import type { OAuth2Profile } from './types' export interface ParseStandardOIDCProfileInput { - tokens: OAuth2Tokens + /** Claims from a verified OIDC ID token (i.e. `VerifiedIDToken.payload`). These take precedence over userinfo. */ + idTokenClaims?: Record + /** Claims from the userinfo endpoint. */ userinfo?: Record } export function parseStandardOIDCProfile(input: ParseStandardOIDCProfileInput): OAuth2Profile { - const claims: Record = { ...(input.userinfo ?? {}), ...(input.tokens.raw ?? {}) } + // Merge userinfo first, then overlay with verified ID-token claims. + // ID-token claims are cryptographically verified; userinfo is not signed, so + // ID-token claims win for security-sensitive fields (sub, email, email_verified). + const claims: Record = { ...(input.userinfo ?? {}), ...(input.idTokenClaims ?? {}) } const subject = pickString(claims.sub) if (!subject) { throw new Error('parseStandardOIDCProfile: missing sub claim') @@ -34,7 +39,10 @@ export function parseGitHubProfile(input: ParseGitHubProfileInput): OAuth2Profil throw new Error('parseGitHubProfile: missing user.id') } const subject = String(id) - const primary = input.emails.find(e => e.primary && e.verified) ?? input.emails.find(e => e.verified) ?? input.emails[0] + // Only include a verified email — falling back to an unverified address is an + // account-linking takeover vector if callers key accounts on email without + // checking emailVerified. + const primary = input.emails.find(e => e.primary && e.verified) ?? input.emails.find(e => e.verified) return { subject, email: primary?.email, diff --git a/node/src/oauth2/random.ts b/node/src/oauth2/random.ts index 30874641..d6399ed3 100644 --- a/node/src/oauth2/random.ts +++ b/node/src/oauth2/random.ts @@ -12,6 +12,9 @@ export function generatePKCE(method: 'S256' | 'plain' = 'S256'): PKCEPair { if (method !== 'S256' && method !== 'plain') { throw new Error(`unknown PKCE method: ${method}`) } + if (method === 'plain') { + console.warn('generatePKCE: "plain" PKCE method is insecure — code_challenge equals code_verifier, providing no protection if the challenge is intercepted; use "S256" instead') + } const codeVerifier = base64url(randomBytes(64)) if (method === 'plain') { return { codeVerifier, codeChallenge: codeVerifier, method } diff --git a/node/src/oauth2/types.ts b/node/src/oauth2/types.ts index 7d2a4425..8bbf4dcf 100644 --- a/node/src/oauth2/types.ts +++ b/node/src/oauth2/types.ts @@ -67,6 +67,11 @@ export function defineOAuth2Provider(opts: OAuth2ProviderConfig): OAuth2Provider const clientAuthMethod = opts.clientAuthMethod !== undefined ? opts.clientAuthMethod : (opts.clientSecret ? 'client_secret_basic' : 'none') + // Public clients (no client secret) should always use PKCE; default to S256. + const pkceMethod = opts.pkceMethod ?? (clientAuthMethod === 'none' ? 'S256' : undefined) + if (pkceMethod === 'plain') { + console.warn('defineOAuth2Provider: pkceMethod "plain" provides no protection if the code_challenge is intercepted; use "S256" instead') + } return { ...opts, authorizationEndpoint: stripTrailingSlash(opts.authorizationEndpoint), @@ -78,7 +83,7 @@ export function defineOAuth2Provider(opts: OAuth2ProviderConfig): OAuth2Provider jwksUri: opts.jwksUri ? stripTrailingSlash(opts.jwksUri) : undefined, endSessionEndpoint: opts.endSessionEndpoint ? stripTrailingSlash(opts.endSessionEndpoint) : undefined, clientAuthMethod, - pkceMethod: opts.pkceMethod + pkceMethod } } diff --git a/node/src/oauth2/wire.ts b/node/src/oauth2/wire.ts index e6ab46a9..b00394d7 100644 --- a/node/src/oauth2/wire.ts +++ b/node/src/oauth2/wire.ts @@ -39,7 +39,7 @@ export async function postForm(init: WireRequestInit, opts?: HttpOptions): Promi try { parsed = JSON.parse(text) } catch { - parsed = text + parsed = undefined } } return { status: response.status, body: parsed } @@ -70,3 +70,8 @@ export function parseTokens(body: any): OAuth2Tokens { function encodeRFC6749(value: string): string { return encodeURIComponent(value).replace(/%20/g, '+') } + +export function safeUpstream(body: any): unknown { + if (!body || typeof body !== 'object') return undefined + return { error: body.error, error_description: body.error_description } +} diff --git a/node/src/store/BlockRef.ts b/node/src/store/BlockRef.ts new file mode 100644 index 00000000..3e6ff6b4 --- /dev/null +++ b/node/src/store/BlockRef.ts @@ -0,0 +1,29 @@ +/** + * Fixed-size (12-byte) pointer stored as a B+ tree leaf value. + * segment(4) + offset(4) + length(4), all little-endian uint32. + */ +export interface BlockRef { + segment: number + offset: number + length: number +} + +export const BLOCK_REF_SIZE = 12 + +export function serializeBlockRef(ref: BlockRef): Uint8Array { + const buf = new Uint8Array(BLOCK_REF_SIZE) + const dv = new DataView(buf.buffer) + dv.setUint32(0, ref.segment, true) + dv.setUint32(4, ref.offset, true) + dv.setUint32(8, ref.length, true) + return buf +} + +export function deserializeBlockRef(buf: Uint8Array): BlockRef { + const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) + return { + segment: dv.getUint32(0, true), + offset: dv.getUint32(4, true), + length: dv.getUint32(8, true), + } +} diff --git a/node/src/store/BlockStore.ts b/node/src/store/BlockStore.ts new file mode 100644 index 00000000..91eaa6f8 --- /dev/null +++ b/node/src/store/BlockStore.ts @@ -0,0 +1,126 @@ +import { open as fsOpen, unlink, readdir } from 'node:fs/promises' +import { constants } from 'node:fs' +import { basename, dirname } from 'node:path' +import type { FileHandle } from 'node:fs/promises' +import type { BlockRef } from './BlockRef' + +const O_FLAGS = constants.O_RDWR | constants.O_CREAT + +/** + * Append-mostly heap storage for NodeStore. + * + * Data is stored in segment files named `.dat.N` (N = uint32 segment ID). + * Only one segment is written to at any time (the "active" segment). Each + * call to `append()` writes at the current end of the active segment and + * returns a BlockRef that encodes which segment and byte range the block + * occupies. + * + * On compaction a new segment is created; old segments are deleted after the + * index is updated and flushed. An unreferenced segment found at `open()` is + * a crash-left orphan and is cleaned up automatically. + */ +export class BlockStore { + private readonly _base: string + private readonly _handles = new Map() + private _activeSegment = 0 + private _writeOffset = 0 + + constructor(basePath: string) { + this._base = basePath + } + + segmentPath(segId: number): string { + return `${this._base}.dat.${segId}` + } + + /** Scan the base directory and return every existing segment's file size. */ + async scanSegments(): Promise> { + const dir = dirname(this._base) + const base = basename(this._base) + const files = await readdir(dir).catch((): string[] => []) + + const sizes = new Map() + for (const f of files) { + const m = f.match(/^(.+)\.dat\.(\d+)$/) + if (m && m[1] === base) { + const segId = parseInt(m[2], 10) + const handle = await this._getHandle(segId) + const stat = await handle.stat() + sizes.set(segId, stat.size) + } + } + return sizes + } + + /** Set which segment to append to and its current EOF position. */ + setActive(segId: number, writeOffset: number): void { + this._activeSegment = segId + this._writeOffset = writeOffset + } + + get activeSegment(): number { return this._activeSegment } + get activeOffset(): number { return this._writeOffset } + + /** Return the segment ID that a new compaction should write to. */ + nextSegmentId(): number { + return this._activeSegment + 1 + } + + private async _getHandle(segId: number): Promise { + const existing = this._handles.get(segId) + if (existing !== undefined) return existing + const handle = await fsOpen(this.segmentPath(segId), O_FLAGS) + this._handles.set(segId, handle) + return handle + } + + /** Append `data` to the active segment and return its BlockRef. */ + async append(data: Uint8Array): Promise { + const handle = await this._getHandle(this._activeSegment) + const offset = this._writeOffset + await handle.write(data, 0, data.length, offset) + this._writeOffset += data.length + return { segment: this._activeSegment, offset, length: data.length } + } + + /** fsync the active segment (durability before index update). */ + async fsyncActive(): Promise { + const h = this._handles.get(this._activeSegment) + if (h !== undefined) await h.sync() + } + + /** Read the block described by `ref`. */ + async read(ref: BlockRef): Promise { + const handle = await this._getHandle(ref.segment) + const buf = new Uint8Array(ref.length) + await handle.read(buf, 0, ref.length, ref.offset) + return buf + } + + /** Write `data` to `segId` at `offset` (used during compaction). */ + async writeAt(segId: number, offset: number, data: Uint8Array): Promise { + const handle = await this._getHandle(segId) + await handle.write(data, 0, data.length, offset) + } + + /** fsync a specific segment (e.g. the new compaction segment). */ + async fsyncSegment(segId: number): Promise { + const h = this._handles.get(segId) + if (h !== undefined) await h.sync() + } + + /** Close handle and delete the segment file. Errors on unlink are swallowed. */ + async deleteSegment(segId: number): Promise { + const h = this._handles.get(segId) + if (h !== undefined) { + await h.close() + this._handles.delete(segId) + } + await unlink(this.segmentPath(segId)).catch(() => {}) + } + + async close(): Promise { + for (const [, h] of this._handles) await h.close() + this._handles.clear() + } +} diff --git a/node/src/store/NodeStore.ts b/node/src/store/NodeStore.ts new file mode 100644 index 00000000..b47dff7c --- /dev/null +++ b/node/src/store/NodeStore.ts @@ -0,0 +1,241 @@ +import { BPlusIndex, FsAdapter } from '@toolcase/base' +import type { RangeOptions } from '@toolcase/base' +import { BlockStore } from './BlockStore' +import type { BlockRef } from './BlockRef' +import { serializeBlockRef, deserializeBlockRef } from './BlockRef' + +export interface NodeStoreOptions { + /** Base path: index → `.idx`, data → `.dat.N`. */ + path: string + compare: (a: K, b: K) => number + serializeKey: (k: K) => Uint8Array + deserializeKey: (b: Uint8Array) => K + serializeValue: (v: V) => Uint8Array + deserializeValue: (b: Uint8Array) => V + /** Passed to BPlusIndex for keyEncoding guard. */ + keyEncoding?: string + /** B+ tree page size in bytes (default 4096). */ + pageSize?: number +} + +/** + * Two-file persistent record store. + * + * The B+ index (`.idx`) stores fixed-size BlockRefs keyed by K. + * The data heap (`.dat.N`) stores serialised values as raw byte blobs. + * + * Write ordering (crash safety): + * 1. Append blob to `.dat.N` then fsync. + * 2. Update the index entry then flush (superblock commit). + * A crash between steps leaves at most a harmless orphan block in the heap; + * the index stays consistent and omits the orphan on the next open(). + * + * Deletes remove only the index entry; the dead block remains in the heap + * until the next `optimize()` call reclaims it. + * + * `optimize()` copies all live blocks to a fresh segment in key order, + * atomically updates the index, then deletes old segments. + */ +export class NodeStore { + + static async open(opts: NodeStoreOptions): Promise> { + const s = new NodeStore(opts) + await s._init() + return s + } + + private readonly _opts: NodeStoreOptions + private _index!: BPlusIndex + private _blocks!: BlockStore + private _liveBytes = 0 + private _totalBytes = 0 + + private constructor(opts: NodeStoreOptions) { + this._opts = opts + } + + private async _init(): Promise { + const { path, compare, serializeKey, deserializeKey, keyEncoding, pageSize } = this._opts + + this._blocks = new BlockStore(path) + const segmentSizes = await this._blocks.scanSegments() + + this._index = await BPlusIndex.open({ + adapter: new FsAdapter(`${path}.idx`, pageSize), + compare, + serializeKey, + deserializeKey, + serializeValue: serializeBlockRef, + deserializeValue: deserializeBlockRef, + keyEncoding, + }) + + // Scan the index to find which segments are live and compute liveBytes. + const referencedSegments = new Set() + let liveBytes = 0 + for await (const [, ref] of this._index.entries()) { + referencedSegments.add(ref.segment) + liveBytes += ref.length + } + this._liveBytes = liveBytes + + // Remove orphan segment files (left by a crashed compaction). + let totalBytes = 0 + for (const [segId, size] of segmentSizes) { + if (referencedSegments.has(segId)) { + totalBytes += size + } else { + await this._blocks.deleteSegment(segId) + } + } + this._totalBytes = totalBytes + + // Set the active segment: largest one referenced by the index, or 0. + const maxRef = referencedSegments.size > 0 + ? Math.max(...referencedSegments) + : 0 + const activeOffset = referencedSegments.has(maxRef) + ? (segmentSizes.get(maxRef) ?? 0) + : 0 + this._blocks.setActive(maxRef, activeOffset) + } + + // ── public accessors ──────────────────────────────────────────────────── + + /** Number of live entries. */ + get size(): number { return this._index.size } + + /** + * Fraction of heap bytes that are referenced by a live index entry. + * `1.0` means no dead space; `0.0` means everything is dead. + * Returns `1.0` when the heap is empty. + */ + get liveRatio(): number { + return this._totalBytes === 0 ? 1 : this._liveBytes / this._totalBytes + } + + // ── CRUD ──────────────────────────────────────────────────────────────── + + async get(key: K): Promise { + const ref = await this._index.get(key) + if (ref === undefined) return undefined + const data = await this._blocks.read(ref) + return this._opts.deserializeValue(data) + } + + /** + * Write `value` under `key`. + * + * Crash-safe ordering: the blob is fsynced to the heap before the index + * entry is updated, so a crash leaves at most a harmless orphan block. + */ + async set(key: K, value: V): Promise { + const data = this._opts.serializeValue(value) + const oldRef = await this._index.get(key) + + // Step 1: append data + fsync. + const ref = await this._blocks.append(data) + await this._blocks.fsyncActive() + + // Step 2: update index (BPlusIndex.set commits the superblock). + await this._index.set(key, ref) + + // Update metrics. + if (oldRef !== undefined) this._liveBytes -= oldRef.length + this._liveBytes += data.length + this._totalBytes += data.length + } + + /** Remove `key`. The block remains in the heap until `optimize()`. */ + async delete(key: K): Promise { + const ref = await this._index.get(key) + if (ref === undefined) return false + const removed = await this._index.delete(key) + if (removed) this._liveBytes -= ref.length + return removed + } + + // ── compaction ─────────────────────────────────────────────────────────── + + /** + * Rewrite the heap in key order, reclaiming dead blocks. + * + * Protocol (crash-safe): + * 1. Copy all live blocks to a new segment file in key order. + * 2. fsync the new segment. + * 3. Atomically update the index via setMany (single superblock commit). + * 4. Delete old segments (now unreferenced). + * + * If a crash occurs before step 3 the new segment is an orphan and is + * cleaned up on the next `open()`. If a crash occurs after step 3 but + * before step 4, old segments are cleaned up on the next `open()`. + */ + async optimize(): Promise { + if (this._index.size === 0) return + + const oldSegId = this._blocks.activeSegment + const newSegId = this._blocks.nextSegmentId() + + const newEntries: [K, BlockRef][] = [] + let newOffset = 0 + + // Step 1: copy live blocks to new segment in key order. + for await (const [key, ref] of this._index.entries()) { + const data = await this._blocks.read(ref) + await this._blocks.writeAt(newSegId, newOffset, data) + newEntries.push([key, { segment: newSegId, offset: newOffset, length: data.length }]) + newOffset += data.length + } + + // Step 2: fsync new segment before touching the index. + await this._blocks.fsyncSegment(newSegId) + + // Step 3: update all index entries in one atomic setMany. + await this._index.setMany(newEntries) + + // Step 4: activate new segment, then delete all old ones. + this._blocks.setActive(newSegId, newOffset) + + for (let i = oldSegId; i >= 0; i--) { + await this._blocks.deleteSegment(i) + } + + // Update metrics: after compaction the heap contains only live bytes. + this._liveBytes = newOffset + this._totalBytes = newOffset + } + + // ── lifecycle ──────────────────────────────────────────────────────────── + + /** fsync both the data heap and the index. */ + async flush(): Promise { + await this._blocks.fsyncActive() + await this._index.flush() + } + + async close(): Promise { + await this._blocks.close() + await this._index.close() + } + + // ── iteration ──────────────────────────────────────────────────────────── + + async * range(opts: RangeOptions = {}): AsyncGenerator<[K, V]> { + for await (const [key, ref] of this._index.range(opts)) { + const data = await this._blocks.read(ref) + yield [key, this._opts.deserializeValue(data)] + } + } + + async * entries(): AsyncGenerator<[K, V]> { + yield * this.range() + } + + async * keys(): AsyncGenerator { + for await (const [k] of this.range()) yield k + } + + async * values(): AsyncGenerator { + for await (const [, v] of this.range()) yield v + } +} diff --git a/node/src/store/index.ts b/node/src/store/index.ts new file mode 100644 index 00000000..12b180e6 --- /dev/null +++ b/node/src/store/index.ts @@ -0,0 +1,5 @@ +export type { BlockRef } from './BlockRef' +export { serializeBlockRef, deserializeBlockRef, BLOCK_REF_SIZE } from './BlockRef' +export { BlockStore } from './BlockStore' +export { NodeStore } from './NodeStore' +export type { NodeStoreOptions } from './NodeStore' diff --git a/node/src/utils/cursor.ts b/node/src/utils/cursor.ts index 5c5d033b..4c9220f5 100644 --- a/node/src/utils/cursor.ts +++ b/node/src/utils/cursor.ts @@ -2,7 +2,14 @@ * Engine-neutral cursor codec for keyset pagination. Encodes a single cursor value (the value of * the ordering column for the last row of a page) into an opaque, type-tagged base64url string, * and decodes it back to the original primitive. Reusable by any `paginateCursor` implementation. + * + * Cursors are unsigned and not integrity-protected — they are opaque by convention only. + * `decodeCursor` validates the payload and throws `ValidationError` on any malformed input + * so callers surface a 400 rather than a 500. */ + +import { ValidationError } from '../errors' + export function encodeCursor(value: unknown): string { let kind: 's' | 'n' | 'b' | 'd' | 'g' let v: string @@ -26,17 +33,33 @@ export function encodeCursor(value: unknown): string { } export function decodeCursor(cursor: string): unknown { - const decoded = Buffer.from(cursor, 'base64url').toString('utf8') + let decoded: string + try { + decoded = Buffer.from(cursor, 'base64url').toString('utf8') + if (!decoded) throw new Error() + } catch { + throw new ValidationError('invalid cursor') + } const idx = decoded.indexOf(':') - if (idx < 0) return decoded + if (idx < 0) throw new ValidationError('invalid cursor') const kind = decoded.slice(0, idx) const v = decoded.slice(idx + 1) switch (kind) { - case 'd': return new Date(v) - case 'g': return BigInt(v) - case 'n': return Number(v) + case 'd': { + const d = new Date(v) + if (Number.isNaN(d.getTime())) throw new ValidationError('invalid cursor') + return d + } + case 'g': { + try { return BigInt(v) } catch { throw new ValidationError('invalid cursor') } + } + case 'n': { + const n = Number(v) + if (!Number.isFinite(n)) throw new ValidationError('invalid cursor') + return n + } case 'b': return v === '1' case 's': return v - default: return v + default: throw new ValidationError('invalid cursor') } } diff --git a/node/src/utils/pagination.ts b/node/src/utils/pagination.ts index 370973f6..15f0ce52 100644 --- a/node/src/utils/pagination.ts +++ b/node/src/utils/pagination.ts @@ -12,6 +12,7 @@ export type PaginationInput = { export interface PaginationOptions { defaultLimit?: number maxLimit?: number + maxOffset?: number strict?: boolean } @@ -42,26 +43,38 @@ export interface CursorPage { hasMore: boolean } +const DECIMAL_INT_RE = /^\d+$/ + +function parseDecimalInt(raw: unknown): number | null { + if (typeof raw === 'number') { + return Number.isInteger(raw) && raw >= 0 ? raw : null + } + if (typeof raw !== 'string') return null + if (!DECIMAL_INT_RE.test(raw)) return null + return parseInt(raw, 10) +} + const clampLimit = (raw: unknown, opts: PaginationOptions): number => { const defaultLimit = opts.defaultLimit ?? DEFAULT_LIMIT const maxLimit = opts.maxLimit ?? DEFAULT_MAX_LIMIT if (raw === undefined || raw === null || raw === '') return defaultLimit - const n = Number(raw) - if (!Number.isFinite(n) || n <= 0) { + const n = parseDecimalInt(raw) + if (n === null || n <= 0) { if (opts.strict) throw new ValidationError(`Invalid limit: ${String(raw)}`) return defaultLimit } - return Math.min(Math.floor(n), maxLimit) + return Math.min(n, maxLimit) } const clampOffset = (raw: unknown, opts: PaginationOptions): number => { if (raw === undefined || raw === null || raw === '') return DEFAULT_OFFSET - const n = Number(raw) - if (!Number.isFinite(n) || n < 0) { + const n = parseDecimalInt(raw) + if (n === null) { if (opts.strict) throw new ValidationError(`Invalid offset: ${String(raw)}`) return DEFAULT_OFFSET } - return Math.floor(n) + const capped = opts.maxOffset !== undefined ? Math.min(n, opts.maxOffset) : n + return capped } export function normalizeOffsetLimit( diff --git a/node/src/utils/parseFilters.ts b/node/src/utils/parseFilters.ts index 5fb1f366..3dc928dc 100644 --- a/node/src/utils/parseFilters.ts +++ b/node/src/utils/parseFilters.ts @@ -1,7 +1,13 @@ import { ValidationError } from '../errors' -import { FieldRule, FieldSchema } from './sanitize' +import { FieldRule, FieldSchema, FORBIDDEN_KEYS } from './sanitize' import { FILTER_OP_SET, Filter, FilterOp } from './filter' +const MAX_MSG_LEN = 100 + +function safeStr(raw: unknown): string { + return String(raw).replace(/[\x00-\x1f\x7f]/g, '').slice(0, MAX_MSG_LEN) +} + export type CoerceType = 'integer' | 'number' | 'boolean' | 'date' export interface ParseFiltersOptions { @@ -21,13 +27,19 @@ export function parseFilters>( query: Record, options: ParseFiltersOptions = {}, ): Filter { + if (!options.allowedFields && !options.schema) { + throw new Error('parseFilters: pass allowedFields or schema; [] to allow none') + } const reserved = new Set(options.reservedKeys ?? DEFAULT_RESERVED) - const allowed = options.allowedFields ? new Set(options.allowedFields) : null + const allowed = options.allowedFields + ? new Set(options.allowedFields) + : new Set(Object.keys(options.schema!)) const out: Record = {} for (const key of Object.keys(query)) { + if (FORBIDDEN_KEYS.has(key)) continue if (reserved.has(key)) continue - if (allowed && !allowed.has(key)) { + if (!allowed.has(key)) { throw new ValidationError(`Unknown filter field: ${key}`) } const raw = query[key] @@ -128,28 +140,28 @@ function coerceLeaf( case 'integer': { const n = Number(raw) if (!Number.isInteger(n)) { - throw new ValidationError(`Filter ${key}[${op}] not an integer: ${raw}`) + throw new ValidationError(`Filter ${key}[${op}] not an integer: ${safeStr(raw)}`) } return n } case 'number': { const n = Number(raw) if (!Number.isFinite(n)) { - throw new ValidationError(`Filter ${key}[${op}] not a number: ${raw}`) + throw new ValidationError(`Filter ${key}[${op}] not a number: ${safeStr(raw)}`) } return n } case 'boolean': { const b = parseBoolean(raw) if (b === undefined) { - throw new ValidationError(`Filter ${key}[${op}] not a boolean: ${raw}`) + throw new ValidationError(`Filter ${key}[${op}] not a boolean: ${safeStr(raw)}`) } return b } case 'date': { const d = new Date(raw) if (Number.isNaN(d.getTime())) { - throw new ValidationError(`Filter ${key}[${op}] not a date: ${raw}`) + throw new ValidationError(`Filter ${key}[${op}] not a date: ${safeStr(raw)}`) } return d } diff --git a/node/src/utils/parseSort.ts b/node/src/utils/parseSort.ts index 4b72ba93..f4b4ea91 100644 --- a/node/src/utils/parseSort.ts +++ b/node/src/utils/parseSort.ts @@ -1,6 +1,12 @@ import { ValidationError } from '../errors' import { Sort } from './sort' +const MAX_MSG_LEN = 100 + +function safeStr(raw: unknown): string { + return String(raw).replace(/[\x00-\x1f\x7f]/g, '').slice(0, MAX_MSG_LEN) +} + export interface ParseSortOptions { allowedFields?: ReadonlyArray } @@ -9,12 +15,15 @@ export function parseSort>( query: Record, options: ParseSortOptions = {}, ): Sort[] | undefined { + if (options.allowedFields === undefined) { + throw new Error('parseSort: pass allowedFields; [] to allow none') + } const raw = query.sort if (raw === undefined || raw === null || raw === '') return undefined if (typeof raw !== 'string') { - throw new ValidationError(`Invalid sort: ${String(raw)}`) + throw new ValidationError(`Invalid sort: ${safeStr(raw)}`) } - const allowed = options.allowedFields ? new Set(options.allowedFields) : null + const allowed = new Set(options.allowedFields) const out: Sort[] = [] for (const tokenRaw of raw.split(',')) { const token = tokenRaw.trim() @@ -28,10 +37,10 @@ export function parseSort>( column = token.slice(1).trim() } if (column === '') { - throw new ValidationError(`Invalid sort token: ${tokenRaw}`) + throw new ValidationError(`Invalid sort token: ${safeStr(tokenRaw)}`) } - if (allowed && !allowed.has(column)) { - throw new ValidationError(`Unknown sort field: ${column}`) + if (!allowed.has(column)) { + throw new ValidationError(`Unknown sort field: ${safeStr(column)}`) } out.push({ field: column as keyof T & string, direction }) } diff --git a/node/src/utils/sanitize/api.ts b/node/src/utils/sanitize/api.ts index 9e4f0076..a5269fc6 100644 --- a/node/src/utils/sanitize/api.ts +++ b/node/src/utils/sanitize/api.ts @@ -1,4 +1,4 @@ -import { pipe, traverseEntity } from './sanitize' +import { pipe, traverseEntity, FORBIDDEN_KEYS } from './sanitize' import { FieldRule, FieldSchema, Visitor } from './types' import { allowOnly, @@ -183,6 +183,7 @@ export function createSanitizer( const source = data as Record const result: Record = {} for (const key of Object.keys(source)) { + if (FORBIDDEN_KEYS.has(key)) continue const field = compiled[key] if (!field) { if (strict) continue @@ -212,6 +213,7 @@ export function createSanitizer( const source = data as Record const result: Record = {} for (const key of Object.keys(source)) { + if (FORBIDDEN_KEYS.has(key)) continue const field = compiled[key] ?? FALLBACK_FIELD if (field.skipOutput) continue if (restricted?.has(key)) continue @@ -227,6 +229,7 @@ export function createSanitizer( ): unknown => { const result: Record = {} for (const key of Object.keys(data)) { + if (FORBIDDEN_KEYS.has(key)) continue if (strict) { if (allowed) { if (!allowed.has(key)) continue diff --git a/node/src/utils/sanitize/jsonSchema.ts b/node/src/utils/sanitize/jsonSchema.ts index f9ef4a66..e3291ca2 100644 --- a/node/src/utils/sanitize/jsonSchema.ts +++ b/node/src/utils/sanitize/jsonSchema.ts @@ -12,6 +12,8 @@ export interface JSONSchemaObject { maxLength?: number minimum?: number maximum?: number + minItems?: number + maxItems?: number enum?: readonly (string | number | boolean | null)[] } @@ -29,7 +31,7 @@ function ruleType(rule: FieldRule): string | undefined { return rule.type } -function ruleToJsonSchema(rule: FieldRule): JSONSchemaObject { +function ruleToJsonSchema(rule: FieldRule, strict: boolean): JSONSchemaObject { const out: JSONSchemaObject = {} const type = ruleType(rule) if (type) out.type = type @@ -40,13 +42,24 @@ function ruleToJsonSchema(rule: FieldRule): JSONSchemaObject { if (rule.min !== undefined) { if (type === 'string') out.minLength = rule.min else if (type && NUMERIC_TYPES.has(type)) out.minimum = rule.min + else if (type === 'array') out.minItems = rule.min } if (rule.max !== undefined) { if (type === 'string') out.maxLength = rule.max else if (type && NUMERIC_TYPES.has(type)) out.maximum = rule.max + else if (type === 'array') out.maxItems = rule.max } if (rule.type === 'array' && rule.items) { - out.items = ruleToJsonSchema(rule.items) + out.items = ruleToJsonSchema(rule.items, strict) + } + if (rule.type === 'object' && rule.fields) { + const properties: Record = {} + for (const [k, fieldRule] of Object.entries(rule.fields)) { + if (!fieldRule) continue + properties[k] = ruleToJsonSchema(fieldRule, strict) + } + out.properties = properties + if (strict) out.additionalProperties = false } return out } @@ -71,7 +84,7 @@ function buildDerivedSchema( if (rule.private) continue if (mode === 'create' && rule.readonly) continue if (mode === 'update' && rule.readonly) continue - properties[key] = ruleToJsonSchema(rule) + properties[key] = ruleToJsonSchema(rule, strict) if (mode === 'create' && rule.required) required.push(key) } const out: JSONSchemaObject = { diff --git a/node/src/utils/sanitize/sanitize.ts b/node/src/utils/sanitize/sanitize.ts index eb6679b4..4f5a8a62 100644 --- a/node/src/utils/sanitize/sanitize.ts +++ b/node/src/utils/sanitize/sanitize.ts @@ -1,5 +1,7 @@ import { FieldSchema, Visitor, VisitorActions, VisitorContext } from './types' +export const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + export function pipe(...visitors: Visitor[]): Visitor { return (ctx, actions) => { for (const visitor of visitors) { @@ -25,6 +27,7 @@ export function traverseEntity>( const result: Record = {} for (const key of Object.keys(source)) { + if (FORBIDDEN_KEYS.has(key)) continue const rule = (schema as Record)[key] as | FieldSchema[keyof T] | undefined diff --git a/node/src/utils/sanitize/types.ts b/node/src/utils/sanitize/types.ts index d72ce79b..344a2274 100644 --- a/node/src/utils/sanitize/types.ts +++ b/node/src/utils/sanitize/types.ts @@ -10,6 +10,9 @@ export interface FieldRule { format?: string enum?: readonly (string | number | boolean | null)[] items?: FieldRule + fields?: Record + filterable?: boolean + sortable?: boolean } export type FieldSchema> = { diff --git a/node/src/utils/sanitize/visitors.ts b/node/src/utils/sanitize/visitors.ts index 0c915ecd..36d0b6ca 100644 --- a/node/src/utils/sanitize/visitors.ts +++ b/node/src/utils/sanitize/visitors.ts @@ -30,10 +30,14 @@ export const allowOnly = (allowed: readonly string[]): Visitor => { } } +const DECIMAL_NUMBER_RE = /^-?(\d+\.?\d*|\.\d+)$/ + export const coerceNumber: Visitor = ({ value }, { set }) => { if (typeof value === 'string' && value.length > 0) { - const n = Number(value) - if (Number.isFinite(n)) set(n) + if (DECIMAL_NUMBER_RE.test(value)) { + const n = Number(value) + if (Number.isFinite(n)) set(n) + } } } diff --git a/node/test/AtlasBuilder.test.ts b/node/test/AtlasBuilder.test.ts index 7c3cd40b..d9eeac45 100644 --- a/node/test/AtlasBuilder.test.ts +++ b/node/test/AtlasBuilder.test.ts @@ -23,6 +23,19 @@ async function writeSolidPng(filePath: string, width: number, height: number, co }).png().toFile(filePath) } +// Writes a PNG whose stored pixel buffer is width×height but whose EXIF orientation tag +// instructs viewers to rotate 90° CW — so the visually-correct size is height×width. +async function writePngWithExifOrientation6(filePath: string, storedWidth: number, storedHeight: number): Promise { + await sharp({ + create: { + width: storedWidth, + height: storedHeight, + channels: 4, + background: { r: 200, g: 100, b: 50, alpha: 1 } + } + }).withMetadata({ orientation: 6 }).png().toFile(filePath) +} + async function readPixel(filePath: string, x: number, y: number): Promise<{ r: number; g: number; b: number; a: number }> { const { data, info } = await sharp(filePath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) const idx = (y * info.width + x) * 4 @@ -212,4 +225,60 @@ describe('AtlasBuilder', () => { const stat = await fs.stat(result.pages[0].file) expect(stat.size).toBeGreaterThan(0) }) + + it('rejects decode when image exceeds maxInputPixels', async () => { + const dir = path.join(workDir, 'pixel-limit') + await fs.mkdir(dir, { recursive: true }) + // 32x32 = 1024 pixels; limit of 100 triggers rejection + const img = path.join(dir, 'big.png') + await writeSolidPng(img, 32, 32, { r: 200, g: 0, b: 0 }) + const out = path.join(workDir, 'out-pixel-limit') + const builder = new AtlasBuilder({ + output: { directory: out }, + maxInputPixels: 100 + }) + await expect(builder.build([{ id: 'big', path: img }])).rejects.toMatchObject({ + name: 'AtlasBuildError', + stage: 'decode' + }) + }) + + it('accepts decode when image is within maxInputPixels', async () => { + const dir = path.join(workDir, 'pixel-limit-ok') + await fs.mkdir(dir, { recursive: true }) + // 8x8 = 64 pixels; limit of 100 is fine + const img = path.join(dir, 'small.png') + await writeSolidPng(img, 8, 8, { r: 0, g: 200, b: 0 }) + const out = path.join(workDir, 'out-pixel-limit-ok') + const result = await new AtlasBuilder({ + output: { directory: out, format: 'png' }, + maxInputPixels: 100, + packer: { padding: 0, allowRotation: false, pot: 'none' }, + useAlphaTrimming: false + }).build([{ id: 'small', path: img }]) + expect(result.pages[0].frames.length).toBe(1) + }) + + it('auto-orients images with EXIF orientation tag (90° CW)', async () => { + const dir = path.join(workDir, 'exif-orient') + await fs.mkdir(dir, { recursive: true }) + // Stored pixels: 40w × 10h, EXIF orientation=6 (90° CW) → visual size: 10w × 40h + const img = path.join(dir, 'rotated.png') + await writePngWithExifOrientation6(img, 40, 10) + + const out = path.join(workDir, 'out-exif-orient') + const result = await new AtlasBuilder({ + output: { directory: out, baseName: 'exif', format: 'png' }, + packer: { padding: 0, allowRotation: false, pot: 'none' }, + useAlphaTrimming: false + }).build([{ id: 'img', path: img }]) + + const frame = result.pages[0].frames[0] + // After auto-orient the frame rect and source dims must reflect the visual size (10×40), + // not the transposed raw stored dims (40×10). + expect(frame.source.width).toBe(10) + expect(frame.source.height).toBe(40) + expect(frame.rect.width).toBe(10) + expect(frame.rect.height).toBe(40) + }) }) diff --git a/node/test/BaseRepository.test.ts b/node/test/BaseRepository.test.ts new file mode 100644 index 00000000..772dd544 --- /dev/null +++ b/node/test/BaseRepository.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest' +import { BaseRepository, BaseRepositoryOptions } from '../src/BaseRepository' + +/** Minimal concrete subclass that exposes `time` for testing. */ +class TestRepo extends BaseRepository { + constructor(options: BaseRepositoryOptions = {}) { + super(null, 'test', 'id', options) + } + + run_time(label: string, fn: () => Promise): Promise { + return this.time(label, fn) + } +} + +describe('BaseRepository.time — telemetry guard', () => { + it('returns the fn value even when onOperation throws', async () => { + const repo = new TestRepo({ + onOperation: () => { throw new Error('telemetry exploded') }, + }) + const result = await repo.run_time('op', async () => 42) + expect(result).toBe(42) + }) + + it('rethrows the real DB error even when onOperation throws', async () => { + const dbError = new Error('db failure') + const repo = new TestRepo({ + onOperation: () => { throw new Error('telemetry exploded') }, + }) + await expect(repo.run_time('op', async () => { throw dbError })).rejects.toThrow('db failure') + }) + + it('returns the fn value even when logger.warn throws', async () => { + const repo = new TestRepo({ + slowQueryMs: 0, + logger: { warn: () => { throw new Error('logger blew up') } } as any, + }) + const result = await repo.run_time('op', async () => 'ok') + expect(result).toBe('ok') + }) + + it('returns the fn value even when logger.debug throws', async () => { + const repo = new TestRepo({ + slowQueryMs: 9999, + logger: { debug: () => { throw new Error('logger blew up') } } as any, + }) + const result = await repo.run_time('op', async () => 'ok') + expect(result).toBe('ok') + }) + + it('calls onOperation with the real db error even on success path', async () => { + const hook = vi.fn() + const repo = new TestRepo({ onOperation: hook }) + await repo.run_time('insert', async () => 1) + expect(hook).toHaveBeenCalledOnce() + expect(hook.mock.calls[0][0]).toMatchObject({ op: 'insert', source: 'test' }) + expect(hook.mock.calls[0][0].error).toBeUndefined() + }) + + it('passes the db error to onOperation when fn throws', async () => { + const hook = vi.fn() + const dbError = new Error('connection lost') + const repo = new TestRepo({ onOperation: hook }) + await expect(repo.run_time('find', async () => { throw dbError })).rejects.toThrow('connection lost') + expect(hook).toHaveBeenCalledOnce() + expect(hook.mock.calls[0][0].error).toBe(dbError) + }) +}) diff --git a/node/test/EntityService.test.ts b/node/test/EntityService.test.ts new file mode 100644 index 00000000..6ae5e6a1 --- /dev/null +++ b/node/test/EntityService.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi } from 'vitest' +import { EntityService, HookContext } from '../src/EntityService' +import { BaseRepository } from '../src/BaseRepository' +import { TransactionClient } from '../src/utils/transaction' + +type FakeDriver = { id: symbol } + +/** Minimal fake repository — only the methods EntityService calls. */ +function makeFakeRepo(overrides: Partial> = {}): BaseRepository { + return { + primaryKey: 'id', + insert: vi.fn(async (values: any) => ({ id: 1, ...values })), + insertMany: vi.fn(async (values: any[]) => values.map((v, i) => ({ id: i + 1, ...v }))), + ...overrides, + } as unknown as BaseRepository +} + +/** + * Fake TransactionClient. Calls `fn` with a fresh driver handle and re-throws on error + * (simulating a rolled-back transaction). Records whether the callback threw. + */ +function makeFakeDb(): { db: TransactionClient; didRollback: () => boolean } { + let rolledBack = false + const db: TransactionClient = { + async transaction(fn: (trx: FakeDriver) => Promise): Promise { + const trx: FakeDriver = { id: Symbol('trx') } + try { + return await fn(trx) + } catch (e) { + rolledBack = true + throw e + } + }, + } + return { db, didRollback: () => rolledBack } +} + +describe('EntityService — transactional hooks', () => { + + describe('insert', () => { + + it('wraps hook+write in a transaction when no trx is supplied', async () => { + const repo = makeFakeRepo() + const { db } = makeFakeDb() + const txSpy = vi.spyOn(db, 'transaction') + + class Svc extends EntityService { + constructor() { super(repo, db) } + } + + const svc = new Svc() + await svc.insert({ name: 'alice' }) + + expect(txSpy).toHaveBeenCalledOnce() + expect(repo.insert).toHaveBeenCalledOnce() + }) + + it('passes the transaction handle to repo.insert', async () => { + let capturedTrx: FakeDriver | undefined + const repo = makeFakeRepo({ + insert: vi.fn(async (values: any, trx: FakeDriver) => { + capturedTrx = trx + return { id: 1, ...values } + }), + }) + const { db } = makeFakeDb() + + class Svc extends EntityService { + constructor() { super(repo, db) } + } + + await new Svc().insert({ name: 'alice' }) + expect(capturedTrx).toBeDefined() + }) + + it('rolls back (propagates throw) when afterInsert throws', async () => { + const repo = makeFakeRepo() + const { db, didRollback } = makeFakeDb() + + class Svc extends EntityService { + constructor() { super(repo, db) } + protected override afterInsert(_row: any, _ctx: HookContext): never { + throw new Error('audit failed') + } + } + + await expect(new Svc().insert({ name: 'bob' })).rejects.toThrow('audit failed') + // repo.insert ran inside the tx, but the tx callback threw → rollback + expect(repo.insert).toHaveBeenCalledOnce() + expect(didRollback()).toBe(true) + }) + + it('uses the caller-supplied trx directly without opening a new one', async () => { + const repo = makeFakeRepo() + const { db } = makeFakeDb() + const txSpy = vi.spyOn(db, 'transaction') + const callerTrx: FakeDriver = { id: Symbol('outer') } + + class Svc extends EntityService { + constructor() { super(repo, db) } + } + + await new Svc().insert({ name: 'carol' }, callerTrx) + expect(txSpy).not.toHaveBeenCalled() + expect(repo.insert).toHaveBeenCalledWith(expect.anything(), callerTrx) + }) + + }) + + describe('insertMany', () => { + + it('runs beforeInsert and afterInsert unconditionally for each row', async () => { + const repo = makeFakeRepo() + const { db } = makeFakeDb() + + const beforeCalls: any[] = [] + const afterCalls: any[] = [] + + class Svc extends EntityService { + constructor() { super(repo, db) } + protected override beforeInsert(values: Record, ctx: HookContext) { + beforeCalls.push({ values, trx: ctx.trx }) + return { ...values, tagged: true } + } + protected override afterInsert(row: any, ctx: HookContext) { + afterCalls.push({ row, trx: ctx.trx }) + return { ...row, audited: true } + } + } + + const results = await new Svc().insertMany([{ name: 'a' }, { name: 'b' }]) + + expect(beforeCalls).toHaveLength(2) + expect(afterCalls).toHaveLength(2) + // beforeInsert mutation is forwarded to the repo + expect(repo.insertMany).toHaveBeenCalledWith( + [{ name: 'a', tagged: true }, { name: 'b', tagged: true }], + expect.anything(), + ) + // afterInsert mutation is reflected in the return value + expect(results[0]).toMatchObject({ audited: true }) + expect(results[1]).toMatchObject({ audited: true }) + }) + + it('runs hooks with the same transaction handle for every row', async () => { + const repo = makeFakeRepo() + const { db } = makeFakeDb() + + const seenTrxs = new Set() + + class Svc extends EntityService { + constructor() { super(repo, db) } + protected override beforeInsert(values: Record, ctx: HookContext) { + if (ctx.trx) seenTrxs.add(ctx.trx) + return values + } + } + + await new Svc().insertMany([{ name: 'a' }, { name: 'b' }, { name: 'c' }]) + + // All three rows share the same transaction handle opened by withTrx + expect(seenTrxs.size).toBe(1) + }) + + it('rolls back when afterInsert throws for any row', async () => { + const repo = makeFakeRepo() + const { db, didRollback } = makeFakeDb() + + class Svc extends EntityService { + constructor() { super(repo, db) } + protected override afterInsert(_row: any, _ctx: HookContext): never { + throw new Error('post-insert hook failed') + } + } + + await expect(new Svc().insertMany([{ name: 'x' }])).rejects.toThrow('post-insert hook failed') + expect(didRollback()).toBe(true) + }) + + it('returns [] immediately without opening a transaction when values is empty', async () => { + const repo = makeFakeRepo() + const { db } = makeFakeDb() + const txSpy = vi.spyOn(db, 'transaction') + + class Svc extends EntityService { + constructor() { super(repo, db) } + } + + const result = await new Svc().insertMany([]) + expect(result).toEqual([]) + expect(txSpy).not.toHaveBeenCalled() + }) + + }) + + describe('upsert', () => { + + it('wraps hook+write in a transaction', async () => { + const repo = makeFakeRepo({ + upsert: vi.fn(async (values: any) => ({ id: 1, ...values })), + }) + const { db } = makeFakeDb() + const txSpy = vi.spyOn(db, 'transaction') + + class Svc extends EntityService { + constructor() { super(repo, db) } + } + + await new Svc().upsert({ name: 'alice' }, { uniqueBy: ['name'] }) + expect(txSpy).toHaveBeenCalledOnce() + }) + + }) + + describe('updateById', () => { + + it('wraps hook+write in a transaction', async () => { + const repo = makeFakeRepo({ + updateById: vi.fn(async (_id: any, values: any) => ({ id: 1, ...values })), + }) + const { db } = makeFakeDb() + const txSpy = vi.spyOn(db, 'transaction') + + class Svc extends EntityService { + constructor() { super(repo, db) } + } + + await new Svc().updateById(1, { name: 'updated' }) + expect(txSpy).toHaveBeenCalledOnce() + }) + + it('rolls back when afterUpdate throws', async () => { + const repo = makeFakeRepo({ + updateById: vi.fn(async (_id: any, values: any) => ({ id: 1, ...values })), + }) + const { db, didRollback } = makeFakeDb() + + class Svc extends EntityService { + constructor() { super(repo, db) } + protected override afterUpdate(_row: any, _ctx: HookContext): never { + throw new Error('audit row failed') + } + } + + await expect(new Svc().updateById(1, { name: 'x' })).rejects.toThrow('audit row failed') + expect(didRollback()).toBe(true) + }) + + }) + + describe('deleteById', () => { + + it('wraps hook+write in a transaction', async () => { + const repo = makeFakeRepo({ + deleteById: vi.fn(async () => 1), + }) + const { db } = makeFakeDb() + const txSpy = vi.spyOn(db, 'transaction') + + class Svc extends EntityService { + constructor() { super(repo, db) } + } + + await new Svc().deleteById(1) + expect(txSpy).toHaveBeenCalledOnce() + }) + + it('rolls back when afterDelete throws', async () => { + const repo = makeFakeRepo({ + deleteById: vi.fn(async () => 1), + }) + const { db, didRollback } = makeFakeDb() + + class Svc extends EntityService { + constructor() { super(repo, db) } + protected override afterDelete(_count: number, _ctx: HookContext): never { + throw new Error('delete audit failed') + } + } + + await expect(new Svc().deleteById(1)).rejects.toThrow('delete audit failed') + expect(didRollback()).toBe(true) + }) + + }) + +}) diff --git a/node/test/HttpServer.test.ts b/node/test/HttpServer.test.ts new file mode 100644 index 00000000..afd82400 --- /dev/null +++ b/node/test/HttpServer.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Capture the options that fastify() is called with. +type CapturedCall = Record +let lastFastifyCall: CapturedCall | undefined + +vi.mock('fastify', () => { + const factory = vi.fn((opts?: CapturedCall) => { + lastFastifyCall = opts ?? {} + return { + register: vi.fn().mockResolvedValue(undefined), + get: vi.fn(), + setNotFoundHandler: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } + }) + return { default: factory } +}) + +import { HttpServer } from '../src/HttpServer' + +beforeEach(() => { + lastFastifyCall = undefined + vi.clearAllMocks() +}) + +describe('HttpServer — fastify options', () => { + + it('trustProxy defaults to false', async () => { + const server = new HttpServer({ port: 3000 }) + await server.init() + expect(lastFastifyCall?.trustProxy).toBe(false) + }) + + it('trustProxy can be opted in', async () => { + const server = new HttpServer({ port: 3000, trustProxy: true }) + await server.init() + expect(lastFastifyCall?.trustProxy).toBe(true) + }) + + it('requestTimeout defaults to 30 000 ms', async () => { + const server = new HttpServer({ port: 3000 }) + await server.init() + expect(lastFastifyCall?.requestTimeout).toBe(30_000) + }) + + it('requestTimeoutMs is forwarded as requestTimeout', async () => { + const server = new HttpServer({ port: 3000, requestTimeoutMs: 5_000 }) + await server.init() + expect(lastFastifyCall?.requestTimeout).toBe(5_000) + }) + + it('connectionTimeout defaults to 0', async () => { + const server = new HttpServer({ port: 3000 }) + await server.init() + expect(lastFastifyCall?.connectionTimeout).toBe(0) + }) + + it('connectionTimeoutMs is forwarded as connectionTimeout', async () => { + const server = new HttpServer({ port: 3000, connectionTimeoutMs: 10_000 }) + await server.init() + expect(lastFastifyCall?.connectionTimeout).toBe(10_000) + }) + + it('keepAliveTimeout defaults to 72 000 ms', async () => { + const server = new HttpServer({ port: 3000 }) + await server.init() + expect(lastFastifyCall?.keepAliveTimeout).toBe(72_000) + }) + + it('keepAliveTimeoutMs is forwarded as keepAliveTimeout', async () => { + const server = new HttpServer({ port: 3000, keepAliveTimeoutMs: 60_000 }) + await server.init() + expect(lastFastifyCall?.keepAliveTimeout).toBe(60_000) + }) +}) diff --git a/node/test/ImageProcessor.test.ts b/node/test/ImageProcessor.test.ts index 554975e9..1438ba28 100644 --- a/node/test/ImageProcessor.test.ts +++ b/node/test/ImageProcessor.test.ts @@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' import sharp from 'sharp' -import { ImageProcessor } from '../src/ImageProcessor' +import { ImageProcessor, MAX_DIMENSION } from '../src/ImageProcessor' import { ImageProcessorError } from '../src/errors' async function makeRedPng(width = 4, height = 4): Promise { @@ -98,6 +98,29 @@ describe('ImageProcessor', () => { expect(meta.height).toBe(16) }) + it('optimize() with no format preserves png input format', async () => { + const buf = await makeRedPng(16, 16) + const out = await ImageProcessor.fromBuffer(buf).optimize().toBuffer() + const meta = await sharp(out).metadata() + expect(meta.format).toBe('png') + }) + + it('optimize() with no format preserves jpeg input format', async () => { + const jpegBuf = await sharp({ + create: { width: 16, height: 16, channels: 3, background: { r: 200, g: 100, b: 50 } } + }).jpeg({ quality: 80 }).toBuffer() + const out = await ImageProcessor.fromBuffer(jpegBuf).optimize().toBuffer() + const meta = await sharp(out).metadata() + expect(meta.format).toBe('jpeg') + }) + + it('optimize() with explicit format applies that encoder', async () => { + const buf = await makeRedPng(16, 16) + const out = await ImageProcessor.fromBuffer(buf).optimize({ format: 'webp' }).toBuffer() + const meta = await sharp(out).metadata() + expect(meta.format).toBe('webp') + }) + it('toFile writes to disk and returns metadata', async () => { const buf = await makeRedPng(12, 12) const dest = path.join(tmpDir, 'out.png') @@ -108,6 +131,90 @@ describe('ImageProcessor', () => { expect(stat.size).toBeGreaterThan(0) }) + it('toFile leaves no partial or temp file on write failure', async () => { + const buf = await makeRedPng(4, 4) + // Target a non-existent subdirectory so sharp.toFile(tmp) fails before writing + const dest = path.join(tmpDir, 'no-such-subdir', 'fail.png') + await expect(ImageProcessor.fromBuffer(buf).toFile(dest)).rejects.toThrow(ImageProcessorError) + // Final destination must not exist + await expect(fs.stat(dest)).rejects.toThrow() + // No .tmp sibling files should linger in tmpDir + const files = await fs.readdir(tmpDir) + expect(files.filter(f => f.endsWith('.tmp'))).toHaveLength(0) + }) + + it('toFile leaves no temp file when rename fails (destination is a directory)', async () => { + const buf = await makeRedPng(4, 4) + // Create a directory at the destination path so rename(tmp, dest) fails + const dest = path.join(tmpDir, 'dest-as-dir') + await fs.mkdir(dest, { recursive: true }) + await expect(ImageProcessor.fromBuffer(buf).toFile(dest)).rejects.toThrow(ImageProcessorError) + // No .tmp files should remain in tmpDir after cleanup + const files = await fs.readdir(tmpDir) + expect(files.filter(f => f.endsWith('.tmp'))).toHaveLength(0) + }) + + it('toFile sets hasAlpha true for 4-channel (RGBA) PNG output', async () => { + const buf = await makeRedPng(4, 4) + const dest = path.join(tmpDir, 'rgba.png') + const meta = await ImageProcessor.fromBuffer(buf).toFile(dest) + expect(meta.hasAlpha).toBe(true) + }) + + it('toFile sets hasAlpha false for 3-channel (RGB) JPEG output', async () => { + const buf = await sharp({ + create: { width: 4, height: 4, channels: 3, background: { r: 200, g: 100, b: 50 } } + }).jpeg().toBuffer() + const dest = path.join(tmpDir, 'rgb.jpg') + const meta = await ImageProcessor.fromBuffer(buf).toFile(dest) + expect(meta.hasAlpha).toBe(false) + }) + + describe('resize() dimension validation', () => { + const proc = () => ImageProcessor.fromBuffer(Buffer.alloc(8)) + + it.each([ + ['negative width', { width: -1, height: 8 }], + ['zero width', { width: 0, height: 8 }], + ['negative height', { width: 8, height: -5 }], + ['zero height', { width: 8, height: 0 }], + ['NaN width', { width: NaN, height: 8 }], + ['NaN height', { width: 8, height: NaN }], + ['fractional width', { width: 1.5, height: 8 }], + ['fractional height', { width: 8, height: 4.9 }], + ['oversized width', { width: MAX_DIMENSION + 1, height: 8 }], + ['oversized height', { width: 8, height: MAX_DIMENSION + 1 }], + ])('throws synchronously on %s', (_, options) => { + expect(() => proc().resize(options)).toThrow(ImageProcessorError) + }) + + it('accepts valid width and height', () => { + expect(() => proc().resize({ width: 100, height: 200 })).not.toThrow() + }) + + it('accepts width-only (aspect-preserving)', () => { + expect(() => proc().resize({ width: 100 })).not.toThrow() + }) + + it('accepts height-only (aspect-preserving)', () => { + expect(() => proc().resize({ height: 200 })).not.toThrow() + }) + + it('accepts MAX_DIMENSION as a valid bound', () => { + expect(() => proc().resize({ width: MAX_DIMENSION, height: MAX_DIMENSION })).not.toThrow() + }) + + it('includes resize-invalid reason', () => { + let err: ImageProcessorError | undefined + try { + proc().resize({ width: -1 }) + } catch (e) { + err = e as ImageProcessorError + } + expect(err?.reason).toBe('resize-invalid') + }) + }) + it('chained transforms fork independently', async () => { const buf = await makeRedPng(20, 20) const base = ImageProcessor.fromBuffer(buf) @@ -118,4 +225,19 @@ describe('ImageProcessor', () => { expect(ma.width).toBe(10) expect(mb.width).toBe(40) }) + + it('rejects image exceeding maxInputPixels', async () => { + // 10x10 = 100 pixels; limit of 50 means it should be rejected + const buf = await makeRedPng(10, 10) + const proc = ImageProcessor.fromBuffer(buf, { maxInputPixels: 50 }) + await expect(proc.toBuffer()).rejects.toThrow(ImageProcessorError) + }) + + it('accepts image within maxInputPixels', async () => { + const buf = await makeRedPng(4, 4) + // 4x4 = 16 pixels; limit of 50 is fine + const out = await ImageProcessor.fromBuffer(buf, { maxInputPixels: 50 }).toBuffer() + const meta = await sharp(out).metadata() + expect(meta.width).toBe(4) + }) }) diff --git a/node/test/NodeStore.test.ts b/node/test/NodeStore.test.ts new file mode 100644 index 00000000..042c2fb5 --- /dev/null +++ b/node/test/NodeStore.test.ts @@ -0,0 +1,401 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { rm } from 'node:fs/promises' +import { BPlusIndex } from '@toolcase/base' +import { NodeStore } from '../src/store/NodeStore' +import { BlockStore } from '../src/store/BlockStore' +import type { NodeStoreOptions } from '../src/store/NodeStore' + +// ── helpers ────────────────────────────────────────────────────────────────── + +const ENC = new TextEncoder() +const DEC = new TextDecoder() + +let _counter = 0 +function tmpBase(): string { + return join(tmpdir(), `node-store-test-${process.pid}-${++_counter}`) +} + +function strOpts(base: string): NodeStoreOptions { + return { + path: base, + ...BPlusIndex.keyPreset.string, + serializeValue: v => ENC.encode(v), + deserializeValue: b => DEC.decode(b), + } +} + +// Cast to access private fields in tests (avoids any as unknown dance in every test). +type InternalStore = { + _blocks: BlockStore +} + +const bases: string[] = [] +function track(base: string): string { bases.push(base); return base } + +afterEach(async () => { + for (const b of bases.splice(0)) { + await rm(`${b}.idx`, { force: true }) + for (let i = 0; i < 20; i++) { + await rm(`${b}.dat.${i}`, { force: true }) + } + } +}) + +// ── basic CRUD ──────────────────────────────────────────────────────────────── + +describe('NodeStore — basic CRUD', () => { + it('set and get a value', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('hello', 'world') + expect(await store.get('hello')).toBe('world') + expect(store.size).toBe(1) + await store.close() + }) + + it('get returns undefined for missing key', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + expect(await store.get('nope')).toBeUndefined() + await store.close() + }) + + it('overwrite updates the value', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('k', 'first') + await store.set('k', 'second') + expect(await store.get('k')).toBe('second') + expect(store.size).toBe(1) + await store.close() + }) + + it('delete removes the entry', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('a', 'A') + expect(await store.delete('a')).toBe(true) + expect(await store.get('a')).toBeUndefined() + expect(store.size).toBe(0) + await store.close() + }) + + it('delete returns false for a missing key', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + expect(await store.delete('ghost')).toBe(false) + await store.close() + }) + + it('size tracks insertions and deletions', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + for (let i = 0; i < 20; i++) await store.set(`k${i}`, `v${i}`) + expect(store.size).toBe(20) + await store.delete('k0') + await store.delete('k1') + expect(store.size).toBe(18) + await store.close() + }) +}) + +// ── persistence ─────────────────────────────────────────────────────────────── + +describe('NodeStore — persistence across close / reopen', () => { + it('all entries survive a reopen', async () => { + const base = track(tmpBase()) + const store1 = await NodeStore.open(strOpts(base)) + for (let i = 0; i < 30; i++) await store1.set(`key-${i}`, `val-${i}`) + expect(store1.size).toBe(30) + await store1.close() + + const store2 = await NodeStore.open(strOpts(base)) + expect(store2.size).toBe(30) + for (let i = 0; i < 30; i++) { + expect(await store2.get(`key-${i}`)).toBe(`val-${i}`) + } + await store2.close() + }) + + it('updates and deletes are durable', async () => { + const base = track(tmpBase()) + const store1 = await NodeStore.open(strOpts(base)) + await store1.set('a', 'first') + await store1.set('b', 'keep') + await store1.close() + + const store2 = await NodeStore.open(strOpts(base)) + await store2.set('a', 'updated') + await store2.delete('b') + await store2.close() + + const store3 = await NodeStore.open(strOpts(base)) + expect(await store3.get('a')).toBe('updated') + expect(await store3.get('b')).toBeUndefined() + expect(store3.size).toBe(1) + await store3.close() + }) +}) + +// ── iteration ───────────────────────────────────────────────────────────────── + +describe('NodeStore — iteration', () => { + it('entries() yields all [key, value] pairs in key order', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('c', 'C') + await store.set('a', 'A') + await store.set('b', 'B') + + const result: [string, string][] = [] + for await (const pair of store.entries()) result.push(pair) + + expect(result).toEqual([['a', 'A'], ['b', 'B'], ['c', 'C']]) + await store.close() + }) + + it('keys() and values() return correct sequences', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('x', '1') + await store.set('y', '2') + + const ks: string[] = [] + for await (const k of store.keys()) ks.push(k) + + const vs: string[] = [] + for await (const v of store.values()) vs.push(v) + + expect(ks).toEqual(['x', 'y']) + expect(vs).toEqual(['1', '2']) + await store.close() + }) + + it('range() with bounds filters correctly', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + // Insert keys: a, b, c, d, e (lexicographic order) + for (const k of ['a', 'b', 'c', 'd', 'e']) await store.set(k, k.toUpperCase()) + + const result: string[] = [] + for await (const [k] of store.range({ gte: 'b', lte: 'd' })) result.push(k) + + expect(result).toEqual(['b', 'c', 'd']) + await store.close() + }) +}) + +// ── liveRatio ───────────────────────────────────────────────────────────────── + +describe('NodeStore — liveRatio', () => { + it('starts at 1 on a fresh store', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + expect(store.liveRatio).toBe(1) + await store.close() + }) + + it('drops below 1 after a delete', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('a', 'x'.repeat(500)) + await store.set('b', 'y'.repeat(500)) + await store.delete('a') + expect(store.liveRatio).toBeLessThan(1) + await store.close() + }) + + it('drops below 1 after an overwrite (old blob becomes dead)', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('key', 'x'.repeat(500)) + await store.set('key', 'y'.repeat(500)) + // Both blobs are in the heap; old one is dead. + expect(store.liveRatio).toBeLessThan(1) + await store.close() + }) + + it('liveRatio is restored correctly across a reopen', async () => { + const base = track(tmpBase()) + const store1 = await NodeStore.open(strOpts(base)) + await store1.set('a', 'x'.repeat(500)) + await store1.set('b', 'y'.repeat(500)) + await store1.delete('a') + const ratioBefore = store1.liveRatio + await store1.close() + + const store2 = await NodeStore.open(strOpts(base)) + expect(store2.liveRatio).toBeCloseTo(ratioBefore, 5) + await store2.close() + }) +}) + +// ── crash-ordering invariant ────────────────────────────────────────────────── + +describe('NodeStore — crash-ordering invariant', () => { + it('an orphan block appended without an index update is invisible after reopen', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('real', 'real-value') + + // Simulate: data fsynced to heap, then process crashed before index.set(). + // Directly append a block to BlockStore without calling NodeStore.set(). + const blocks = (store as unknown as InternalStore)._blocks + await blocks.append(ENC.encode('orphan-value')) + await blocks.fsyncActive() + // Intentionally omit: await store.set('orphan-key', 'orphan-value') + + await store.close() + + // After reopen the orphan block is in the heap but not in the index. + const store2 = await NodeStore.open(strOpts(base)) + + expect(await store2.get('real')).toBe('real-value') + expect(await store2.get('orphan-key')).toBeUndefined() + expect(store2.size).toBe(1) + + // The orphan inflates totalBytes → liveRatio < 1. + expect(store2.liveRatio).toBeLessThan(1) + + await store2.close() + }) + + it('optimize() reclaims orphan blocks left by a simulated crash', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('a', 'alpha') + await store.set('b', 'beta') + + // Append an orphan without an index entry. + const blocks = (store as unknown as InternalStore)._blocks + await blocks.append(ENC.encode('orphan')) + await blocks.fsyncActive() + + await store.close() + + const store2 = await NodeStore.open(strOpts(base)) + expect(store2.liveRatio).toBeLessThan(1) + + await store2.optimize() + + expect(store2.liveRatio).toBeCloseTo(1, 5) + expect(await store2.get('a')).toBe('alpha') + expect(await store2.get('b')).toBe('beta') + await store2.close() + }) +}) + +// ── optimize / compaction ───────────────────────────────────────────────────── + +describe('NodeStore — optimize (compaction)', () => { + it('reclaims dead space after deletes and keeps remaining entries intact', async () => { + const PAYLOAD = 'x'.repeat(1000) + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + + for (let i = 0; i < 10; i++) await store.set(`key-${i}`, PAYLOAD) + for (let i = 0; i < 5; i++) await store.delete(`key-${i}`) + + expect(store.size).toBe(5) + expect(store.liveRatio).toBeLessThan(1) + + await store.optimize() + + expect(store.size).toBe(5) + expect(store.liveRatio).toBeCloseTo(1, 5) + for (let i = 5; i < 10; i++) { + expect(await store.get(`key-${i}`)).toBe(PAYLOAD) + } + await store.close() + }) + + it('survives a reopen after compaction', async () => { + const base = track(tmpBase()) + const store1 = await NodeStore.open(strOpts(base)) + for (let i = 0; i < 20; i++) await store1.set(`k${i}`, `v${i}`) + for (let i = 0; i < 10; i++) await store1.delete(`k${i}`) + + await store1.optimize() + await store1.close() + + const store2 = await NodeStore.open(strOpts(base)) + expect(store2.size).toBe(10) + for (let i = 10; i < 20; i++) { + expect(await store2.get(`k${i}`)).toBe(`v${i}`) + } + expect(store2.liveRatio).toBeCloseTo(1, 5) + await store2.close() + }) + + it('optimize() on an empty store is a no-op', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await expect(store.optimize()).resolves.toBeUndefined() + expect(store.size).toBe(0) + await store.close() + }) + + it('compaction produces entries in key order', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + + // Insert in non-sorted order, then compact. + await store.set('c', 'C') + await store.set('a', 'A') + await store.set('b', 'B') + await store.delete('a') + + await store.optimize() + + const keys: string[] = [] + for await (const [k] of store.entries()) keys.push(k) + expect(keys).toEqual(['b', 'c']) + + await store.close() + }) + + it('orphan segment left by a crashed compaction is cleaned up on reopen', async () => { + // Arrange: a valid store with two entries. + const base = track(tmpBase()) + const store1 = await NodeStore.open(strOpts(base)) + await store1.set('a', 'alpha') + await store1.set('b', 'beta') + await store1.close() + + // Simulate a crashed compaction: segment 1 appears on disk but the + // index superblock was never committed to reference it. + const orphanStore = new BlockStore(base) + await orphanStore.scanSegments() // opens handles for existing files + // Write some bytes at segment 1 offset 0 (simulates partial compaction write). + await orphanStore.writeAt(1, 0, ENC.encode('orphan-compaction-data')) + await orphanStore.fsyncSegment(1) + await orphanStore.close() + + // Reopen NodeStore: segment 1 should be detected as unreferenced and deleted. + const store2 = await NodeStore.open(strOpts(base)) + + expect(store2.size).toBe(2) + expect(await store2.get('a')).toBe('alpha') + expect(await store2.get('b')).toBe('beta') + + // Only segment 0 remains; its bytes are all live → liveRatio ≈ 1. + expect(store2.liveRatio).toBeCloseTo(1, 5) + + await store2.close() + }) +}) + +// ── flush ───────────────────────────────────────────────────────────────────── + +describe('NodeStore — flush', () => { + it('flush() does not throw and leaves data intact', async () => { + const base = track(tmpBase()) + const store = await NodeStore.open(strOpts(base)) + await store.set('k', 'v') + await store.flush() + expect(await store.get('k')).toBe('v') + await store.close() + }) +}) diff --git a/node/test/RESTRouteHandler.test.ts b/node/test/RESTRouteHandler.test.ts index 250561f4..9c2919f7 100644 --- a/node/test/RESTRouteHandler.test.ts +++ b/node/test/RESTRouteHandler.test.ts @@ -195,6 +195,54 @@ describe('RESTRouteHandler.list', () => { expect(state.paginate.mock.calls[0][0].where).toEqual({ age: { gte: '18' } }) }) + it('schema without filterable flag → field rejected even though readable', async () => { + const { list } = setup({ schema: { email: {}, status: {} } }) + const { reply, code } = makeReply() + const body = await list.handler(makeReq({ query: { email: 'x' } }), reply) as { cause: string } + expect(code).toHaveBeenCalledWith(400) + expect(body.cause).toBe('VALIDATION_ERROR') + }) + + it('schema with filterable:true → field accepted for filtering', async () => { + const { state, list } = setup({ schema: { email: { filterable: true }, status: {} } }) + state.paginate.mockResolvedValue({ results: [], pagination: { offset: 0, limit: 25, count: 0 } }) + const { reply } = makeReply() + await list.handler(makeReq({ query: { email: 'x' } }), reply) + expect(state.paginate.mock.calls[0][0].where).toEqual({ email: 'x' }) + }) + + it('schema without sortable flag → sort on that field is rejected', async () => { + const { list } = setup({ schema: { createdAt: {} } }) + const { reply, code } = makeReply() + const body = await list.handler(makeReq({ query: { sort: 'createdAt' } }), reply) as { cause: string } + expect(code).toHaveBeenCalledWith(400) + expect(body.cause).toBe('VALIDATION_ERROR') + }) + + it('schema with sortable:true → sort on that field is accepted', async () => { + const { state, list } = setup({ schema: { createdAt: { sortable: true } } }) + state.paginate.mockResolvedValue({ results: [], pagination: { offset: 0, limit: 25, count: 0 } }) + const { reply } = makeReply() + await list.handler(makeReq({ query: { sort: 'createdAt' } }), reply) + expect(state.paginate.mock.calls[0][0].orderBy).toEqual([{ field: 'createdAt', direction: 'asc' }]) + }) + + it('explicit filterableFields override schema flags', async () => { + const { state, list } = setup({ filterableFields: ['status'], schema: { status: {} } }) + state.paginate.mockResolvedValue({ results: [], pagination: { offset: 0, limit: 25, count: 0 } }) + const { reply } = makeReply() + await list.handler(makeReq({ query: { status: 'active' } }), reply) + expect(state.paginate.mock.calls[0][0].where).toEqual({ status: 'active' }) + }) + + it('explicit sortableFields override schema flags', async () => { + const { state, list } = setup({ sortableFields: ['name'], schema: { name: {} } }) + state.paginate.mockResolvedValue({ results: [], pagination: { offset: 0, limit: 25, count: 0 } }) + const { reply } = makeReply() + await list.handler(makeReq({ query: { sort: 'name' } }), reply) + expect(state.paginate.mock.calls[0][0].orderBy).toEqual([{ field: 'name', direction: 'asc' }]) + }) + it('hides restrictedOutputFields on every row', async () => { const { state, list } = setup({ restrictedOutputFields: ['passwordHash'] }) state.paginate.mockResolvedValue({ @@ -206,6 +254,48 @@ describe('RESTRouteHandler.list', () => { expect(out.data[0].passwordHash).toBeUndefined() expect(out.data[1].passwordHash).toBeUndefined() }) + + it('strictQuery strips non-schema keys before parseSort/parseFilters', async () => { + const { state, list } = setup({ + schema: { email: { filterable: true } }, + filterableFields: ['email'], + strictQuery: true, + allowedQueryKeys: ['offset', 'limit'], + }) + state.paginate.mockResolvedValue({ results: [], pagination: { offset: 0, limit: 25, count: 0 } }) + const { reply } = makeReply() + // 'unknown_key' should be silently stripped by sanitizeQuery before reaching parseFilters + await list.handler(makeReq({ query: { email: 'x', unknown_key: 'y' } }), reply) + expect(state.paginate).toHaveBeenCalledTimes(1) + const arg = state.paginate.mock.calls[0][0] + expect(arg.where).toEqual({ email: 'x' }) + }) + + it('allowedQueryKeys preserves extra keys beyond schema in strict mode', async () => { + const { state, list } = setup({ + schema: { email: { filterable: true } }, + filterableFields: ['email'], + sortableFields: ['email'], + strictQuery: true, + allowedQueryKeys: ['sort', 'offset', 'limit'], + }) + state.paginate.mockResolvedValue({ results: [], pagination: { offset: 0, limit: 25, count: 0 } }) + const { reply } = makeReply() + await list.handler(makeReq({ query: { sort: 'email', evil: 'x' } }), reply) + expect(state.paginate.mock.calls[0][0].orderBy).toEqual([{ field: 'email', direction: 'asc' }]) + }) + + it('schema private field stripped from query by sanitizeQuery', async () => { + const { state, list } = setup({ + schema: { email: { filterable: true }, passwordHash: { private: true } }, + filterableFields: ['email', 'passwordHash'], + }) + state.paginate.mockResolvedValue({ results: [], pagination: { offset: 0, limit: 25, count: 0 } }) + const { reply } = makeReply() + await list.handler(makeReq({ query: { email: 'x', passwordHash: 'secret' } }), reply) + // passwordHash is private and must be stripped from query before parseFilters + expect(state.paginate.mock.calls[0][0].where).toEqual({ email: 'x' }) + }) }) describe('RESTRouteHandler.getOne', () => { diff --git a/node/test/RouteHandler.test.ts b/node/test/RouteHandler.test.ts index a5bfe3c2..5ba5a780 100644 --- a/node/test/RouteHandler.test.ts +++ b/node/test/RouteHandler.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import type { FastifyInstance, FastifyReply } from 'fastify' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { RouteHandler } from '../src/RouteHandler' import { NotFoundError, @@ -17,6 +17,10 @@ class TestRouteHandler extends RouteHandler { callMapError(error: unknown, reply: FastifyReply): unknown { return this.mapError(error, reply) } + + callParseId(req: FastifyRequest): unknown { + return this.parseId(req) + } } function makeReply() { @@ -84,3 +88,40 @@ describe('RouteHandler.mapError', () => { expect(body.cause).toBe('internal_error') }) }) + +describe('RouteHandler.parseId', () => { + + function makeReq(idValue: string) { + return { params: { id: idValue } } as unknown as FastifyRequest + } + + it('custom parseId throwing non-ValidationError yields ValidationError with no details', () => { + const ep = new TestRouteHandler({ + parseId: () => { throw new Error('secret internal detail') }, + }) + let caught: unknown + try { + ep.callParseId(makeReq('bad')) + } catch (e) { + caught = e + } + expect(caught).toBeInstanceOf(ValidationError) + expect((caught as ValidationError).details).toBeUndefined() + expect(String(caught)).not.toContain('secret internal detail') + }) + + it('custom parseId throwing ValidationError passes it through unchanged', () => { + const ep = new TestRouteHandler({ + parseId: () => { throw new ValidationError('custom message', { field: 'id' }) }, + }) + let caught: unknown + try { + ep.callParseId(makeReq('bad')) + } catch (e) { + caught = e + } + expect(caught).toBeInstanceOf(ValidationError) + expect((caught as ValidationError).details).toEqual({ field: 'id' }) + expect(String(caught)).toContain('custom message') + }) +}) diff --git a/node/test/cursor.test.ts b/node/test/cursor.test.ts new file mode 100644 index 00000000..911df659 --- /dev/null +++ b/node/test/cursor.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest' +import { encodeCursor, decodeCursor } from '../src/utils/cursor' +import { ValidationError } from '../src/errors' + +describe('cursor round-trips', () => { + + it('encodes and decodes a string', () => { + const cursor = encodeCursor('hello') + expect(decodeCursor(cursor)).toBe('hello') + }) + + it('encodes and decodes a number', () => { + const cursor = encodeCursor(42.5) + expect(decodeCursor(cursor)).toBe(42.5) + }) + + it('encodes and decodes a bigint', () => { + const cursor = encodeCursor(9007199254740993n) + expect(decodeCursor(cursor)).toBe(9007199254740993n) + }) + + it('encodes and decodes a Date', () => { + const date = new Date('2024-01-15T12:00:00.000Z') + const cursor = encodeCursor(date) + const decoded = decodeCursor(cursor) as Date + expect(decoded).toBeInstanceOf(Date) + expect(decoded.toISOString()).toBe(date.toISOString()) + }) + + it('encodes and decodes a boolean true', () => { + expect(decodeCursor(encodeCursor(true))).toBe(true) + }) + + it('encodes and decodes a boolean false', () => { + expect(decodeCursor(encodeCursor(false))).toBe(false) + }) +}) + +describe('decodeCursor rejects tampered input', () => { + + it('throws ValidationError for garbage input', () => { + expect(() => decodeCursor('not-a-real-cursor!!!')).toThrow(ValidationError) + }) + + it('throws ValidationError for empty string', () => { + expect(() => decodeCursor('')).toThrow(ValidationError) + }) + + it('throws ValidationError for a tampered bigint (g: branch)', () => { + // base64url-encode a tampered "g:" payload + const tampered = Buffer.from('g:not-a-bigint', 'utf8').toString('base64url') + expect(() => decodeCursor(tampered)).toThrow(ValidationError) + }) + + it('throws ValidationError for a malformed date (d: branch)', () => { + const tampered = Buffer.from('d:not-a-date', 'utf8').toString('base64url') + expect(() => decodeCursor(tampered)).toThrow(ValidationError) + }) + + it('throws ValidationError for a non-finite number (n: branch)', () => { + const tampered = Buffer.from('n:NaN', 'utf8').toString('base64url') + expect(() => decodeCursor(tampered)).toThrow(ValidationError) + }) + + it('throws ValidationError for an unknown type tag', () => { + const tampered = Buffer.from('z:something', 'utf8').toString('base64url') + expect(() => decodeCursor(tampered)).toThrow(ValidationError) + }) + + it('throws ValidationError for payload with no colon', () => { + const tampered = Buffer.from('nocursorformat', 'utf8').toString('base64url') + expect(() => decodeCursor(tampered)).toThrow(ValidationError) + }) + + it('error message does not echo the raw cursor', () => { + const raw = 'g:totally-tampered-value' + const tampered = Buffer.from(raw, 'utf8').toString('base64url') + let message = '' + try { + decodeCursor(tampered) + } catch (e) { + message = (e as Error).message + } + expect(message).not.toContain(raw) + expect(message).not.toContain(tampered) + }) +}) diff --git a/node/test/env.test.ts b/node/test/env.test.ts index de3a493d..c9e81082 100644 --- a/node/test/env.test.ts +++ b/node/test/env.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import env from '../src/env' describe('env', () => { @@ -30,6 +30,41 @@ describe('env', () => { delete process.env.NUM_VAR }) + it('warns when env value is present but unparseable as number', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + process.env.NUM_WARN = 'abc' + env('NUM_WARN', 0, 'number') + expect(warn).toHaveBeenCalledOnce() + expect(warn.mock.calls[0][0]).toContain('NUM_WARN') + delete process.env.NUM_WARN + warn.mockRestore() + }) + + it('warns for leading-zero octal-like values (007)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + process.env.NUM_007 = '007' + env('NUM_007', 0, 'number') + expect(warn).toHaveBeenCalledOnce() + delete process.env.NUM_007 + warn.mockRestore() + }) + + it('warns for whitespace-padded values (" 42")', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + process.env.NUM_SPACE = ' 42' + env('NUM_SPACE', 0, 'number') + expect(warn).toHaveBeenCalledOnce() + delete process.env.NUM_SPACE + warn.mockRestore() + }) + + it('does not warn when env number value is missing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(env('TOTALLY_MISSING_NUM_VAR', 7, 'number')).toBe(7) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + it('parses boolean type true', () => { process.env.BOOL_VAR = 'true' expect(env('BOOL_VAR', false, 'boolean')).toBe(true) diff --git a/node/test/kv/KVService.test.ts b/node/test/kv/KVService.test.ts new file mode 100644 index 00000000..1f37024a --- /dev/null +++ b/node/test/kv/KVService.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from 'vitest' +import { KVService } from '../../src/kv/KVService' +import { KV_LUA_SCRIPTS } from '../../src/kv/scripts' + +function makeMinimalClient(scriptLoadImpl?: () => Promise) { + const client: Record = { + evalSha: vi.fn().mockResolvedValue([]), + eval: vi.fn().mockResolvedValue([]), + scriptLoad: scriptLoadImpl + ? vi.fn().mockImplementation(scriptLoadImpl) + : vi.fn().mockResolvedValue('ok'), + duplicate: vi.fn().mockReturnValue({}), + } + client.withTypeMapping = vi.fn().mockReturnValue(client) + return client +} + +function makeKVService(client: Record) { + return new KVService({ + client: client as never, + namespace: 'test', + }) +} + +describe('KVService.warmScripts', () => { + + it('resolves and calls scriptLoad once per registered script', async () => { + const client = makeMinimalClient() + const kv = makeKVService(client) + await expect(kv.warmScripts()).resolves.toBeUndefined() + const scriptLoad = client.scriptLoad as ReturnType + expect(scriptLoad).toHaveBeenCalledTimes(Object.keys(KV_LUA_SCRIPTS).length) + }) + + it('propagates a scriptLoad failure instead of swallowing it', async () => { + const client = makeMinimalClient(() => Promise.reject(new Error('LOADING Redis'))) + const kv = makeKVService(client) + await expect(kv.warmScripts()).rejects.toThrow('LOADING Redis') + }) +}) + +describe('KVService.popN', () => { + + it('clamps count to 1000 when a larger value is passed', async () => { + const client = makeMinimalClient() + const evalSha = client.evalSha as ReturnType + evalSha.mockResolvedValue(['a', 'b']) + const kv = makeKVService(client) + const result = await kv.popN('queue', 9999) + expect(result).toEqual(['a', 'b']) + // evalSha is called by LuaScript.run + const call = evalSha.mock.calls[0] + expect(call[1].arguments[0]).toBe('1000') + }) + + it('passes the original count when it is within the cap', async () => { + const client = makeMinimalClient() + const evalSha = client.evalSha as ReturnType + evalSha.mockResolvedValue(['x']) + const kv = makeKVService(client) + await kv.popN('queue', 50) + const call = evalSha.mock.calls[0] + expect(call[1].arguments[0]).toBe('50') + }) + + it('returns an empty array when the script returns a non-array', async () => { + const client = makeMinimalClient() + const evalSha = client.evalSha as ReturnType + evalSha.mockResolvedValue(null) + const kv = makeKVService(client) + const result = await kv.popN('queue', 5) + expect(result).toEqual([]) + }) + + it('Lua script clamps count with math.min in source', () => { + expect(KV_LUA_SCRIPTS.popN).toContain('math.min(tonumber(ARGV[1]), 1000)') + }) +}) diff --git a/node/test/kv/KeyBuilder.test.ts b/node/test/kv/KeyBuilder.test.ts new file mode 100644 index 00000000..ce6f77c5 --- /dev/null +++ b/node/test/kv/KeyBuilder.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { KeyBuilder, KV_KEY_SEPARATOR } from '../../src/kv/keys' + +function makeBuilder(namespace = 'ns') { + return new KeyBuilder(namespace, KV_KEY_SEPARATOR) +} + +describe('KeyBuilder.build — separator escaping', () => { + + it('encodes the separator in a single part', () => { + const kb = makeBuilder('user') + const key = kb.build('a:b') + expect(key).toBe('user:a%3Ab') + }) + + it('build("user","a:b") and build("user:a","b") produce distinct keys', () => { + const kb = new KeyBuilder('', KV_KEY_SEPARATOR) + const k1 = kb.build('user', 'a:b') + const k2 = kb.build('user:a', 'b') + expect(k1).not.toBe(k2) + }) + + it('plain parts without the separator pass through unchanged', () => { + const kb = makeBuilder('ns') + expect(kb.build('foo', 'bar')).toBe('ns:foo:bar') + }) + + it('encodes the separator in multiple parts independently', () => { + const kb = makeBuilder('ns') + expect(kb.build('a:b', 'c:d')).toBe('ns:a%3Ab:c%3Ad') + }) + + it('numeric parts pass through unchanged', () => { + const kb = makeBuilder('ns') + expect(kb.build(42)).toBe('ns:42') + }) + + it('encodes separator when namespace is empty — single part', () => { + const kb = new KeyBuilder('', KV_KEY_SEPARATOR) + expect(kb.build('a:b')).toBe('a%3Ab') + }) + +}) + +describe('KeyBuilder.scope — separator escaping', () => { + + it('encodes the separator in the namespace segment', () => { + const kb = makeBuilder('root') + const scoped = kb.scope('a:b') + expect(scoped.namespace).toBe('root:a%3Ab') + }) + + it('scoped builders keep the same separator', () => { + const kb = makeBuilder('root') + const scoped = kb.scope('sub') + expect(scoped.separator).toBe(KV_KEY_SEPARATOR) + }) + +}) + +describe('KeyBuilder.stripNamespace', () => { + + it('strips a plain namespace prefix', () => { + const kb = makeBuilder('ns') + expect(kb.stripNamespace('ns:foo')).toBe('foo') + }) + + it('returns the value unchanged when it does not start with the prefix', () => { + const kb = makeBuilder('ns') + expect(kb.stripNamespace('other:foo')).toBe('other:foo') + }) + +}) diff --git a/node/test/kv/Leaderboard.test.ts b/node/test/kv/Leaderboard.test.ts new file mode 100644 index 00000000..0431e04f --- /dev/null +++ b/node/test/kv/Leaderboard.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest' +import { Leaderboard } from '../../src/kv/Leaderboard' +import { KeyBuilder, KV_KEY_SEPARATOR } from '../../src/kv/keys' +import { LuaScriptCache } from '../../src/kv/scripts' + +function makeClient(evalReply: unknown, rankReply: unknown = 0) { + return { + evalSha: vi.fn().mockResolvedValue(evalReply), + eval: vi.fn().mockResolvedValue(evalReply), + zRevRank: vi.fn().mockResolvedValue(rankReply), + zRank: vi.fn().mockResolvedValue(rankReply), + // zAdd returns added-count (0 when member already existed, 1 when new) + zAdd: vi.fn().mockResolvedValue(0), + zIncrBy: vi.fn().mockResolvedValue(0), + } +} + +function makeLeaderboard(client: unknown, direction?: 'asc' | 'desc') { + const keys = new KeyBuilder('test', KV_KEY_SEPARATOR) + const scripts = new LuaScriptCache() + return new Leaderboard(client as never, keys, scripts, direction ? { direction } : {}) +} + +describe('Leaderboard.addScore', () => { + + it('returns void (not the ZADD added-count) when updating an existing member', async () => { + // zAdd returns 0 when the member already existed but its score was updated — + // the old implementation forwarded that 0 to callers who expected the stored score. + const client = makeClient(null) + client.zAdd.mockResolvedValue(0) + const board = makeLeaderboard(client) + const result = await board.addScore('scores', 'alice', 42) + expect(result).toBeUndefined() + }) + + it('calls zAdd with the correct key and score payload', async () => { + const client = makeClient(null) + const board = makeLeaderboard(client) + await board.addScore('scores', 'bob', 99) + expect(client.zAdd).toHaveBeenCalledOnce() + expect(client.zAdd).toHaveBeenCalledWith('test:scores', { score: 99, value: 'bob' }) + }) +}) + +describe('Leaderboard.incrScore', () => { + + it('returns a number when zIncrBy resolves with a string float', async () => { + const client = makeClient(null) + client.zIncrBy.mockResolvedValue('5.5') + const board = makeLeaderboard(client) + const result = await board.incrScore('scores', 'alice', 0.5) + expect(typeof result).toBe('number') + expect(result).toBe(5.5) + }) + + it('returns a number when zIncrBy resolves with an integer', async () => { + const client = makeClient(null) + client.zIncrBy.mockResolvedValue(10) + const board = makeLeaderboard(client) + const result = await board.incrScore('scores', 'alice', 1) + expect(typeof result).toBe('number') + expect(result).toBe(10) + }) + + it('calls zIncrBy with the correct key, delta and member', async () => { + const client = makeClient(null) + client.zIncrBy.mockResolvedValue('42') + const board = makeLeaderboard(client) + await board.incrScore('scores', 'bob', 7) + expect(client.zIncrBy).toHaveBeenCalledOnce() + expect(client.zIncrBy).toHaveBeenCalledWith('test:scores', 7, 'bob') + }) +}) + +describe('Leaderboard.addScoreAndRank direction', () => { + + it('passes "desc" as the third script argument when direction is desc (default)', async () => { + const client = makeClient([2, '100']) + const board = makeLeaderboard(client) + await board.addScoreAndRank('scores', 'alice', 100) + const call = client.evalSha.mock.calls[0] + const args: string[] = call[1].arguments + expect(args[2]).toBe('desc') + }) + + it('passes "asc" as the third script argument when direction is asc', async () => { + const client = makeClient([2, '100']) + const board = makeLeaderboard(client, 'asc') + await board.addScoreAndRank('scores', 'alice', 100) + const call = client.evalSha.mock.calls[0] + const args: string[] = call[1].arguments + expect(args[2]).toBe('asc') + }) + + it('addScoreAndRank and rankOf agree for direction asc', async () => { + // Both should return rank 2 for the same member + const client = makeClient([2, '50'], 2) + const board = makeLeaderboard(client, 'asc') + const { rank: rankFromAdd } = await board.addScoreAndRank('scores', 'bob', 50) + const rankFromQuery = await board.rankOf('scores', 'bob') + expect(rankFromAdd).toBe(rankFromQuery) + // rankOf for asc uses zRank, not zRevRank + expect(client.zRank).toHaveBeenCalledOnce() + expect(client.zRevRank).not.toHaveBeenCalled() + }) + + it('addScoreAndRank and rankOf agree for direction desc', async () => { + const client = makeClient([0, '200'], 0) + const board = makeLeaderboard(client, 'desc') + const { rank: rankFromAdd } = await board.addScoreAndRank('scores', 'carol', 200) + const rankFromQuery = await board.rankOf('scores', 'carol') + expect(rankFromAdd).toBe(rankFromQuery) + // rankOf for desc uses zRevRank + expect(client.zRevRank).toHaveBeenCalledOnce() + expect(client.zRank).not.toHaveBeenCalled() + }) +}) diff --git a/node/test/kv/RateLimiter.test.ts b/node/test/kv/RateLimiter.test.ts new file mode 100644 index 00000000..d79a2d42 --- /dev/null +++ b/node/test/kv/RateLimiter.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi } from 'vitest' +import { RateLimiter } from '../../src/kv/RateLimiter' +import { KeyBuilder, KV_KEY_SEPARATOR } from '../../src/kv/keys' +import { LuaScriptCache, KV_LUA_SCRIPTS } from '../../src/kv/scripts' + +function makeClient(reply: unknown) { + return { + evalSha: vi.fn().mockResolvedValue(reply), + eval: vi.fn().mockResolvedValue(reply), + } +} + +function makeRateLimiter(client: unknown) { + const keys = new KeyBuilder('test', KV_KEY_SEPARATOR) + const scripts = new LuaScriptCache() + return new RateLimiter(client as never, keys, scripts) +} + +describe('RateLimiter.incrCapped', () => { + + it('returns allowed=true and current value when under cap', async () => { + const client = makeClient([1, 3]) + const limiter = makeRateLimiter(client) + const result = await limiter.incrCapped('k', 1, 10, 60) + expect(result.allowed).toBe(true) + expect(result.current).toBe(3) + }) + + it('returns allowed=false when at cap', async () => { + const client = makeClient([0, 10]) + const limiter = makeRateLimiter(client) + const result = await limiter.incrCapped('k', 1, 10) + expect(result.allowed).toBe(false) + expect(result.current).toBe(10) + }) + + it('passes ttl as empty string when not provided', async () => { + const client = makeClient([1, 1]) + const limiter = makeRateLimiter(client) + await limiter.incrCapped('k', 1, 10) + const args: string[] = client.evalSha.mock.calls[0][1].arguments + expect(args[2]).toBe('') + }) + + it('passes ttl as string when provided', async () => { + const client = makeClient([1, 1]) + const limiter = makeRateLimiter(client) + await limiter.incrCapped('k', 1, 10, 60) + const args: string[] = client.evalSha.mock.calls[0][1].arguments + expect(args[2]).toBe('60') + }) + + it('Lua script sets TTL only on new key (is_new guard present in source)', () => { + expect(KV_LUA_SCRIPTS.incrCapped).toContain('is_new') + expect(KV_LUA_SCRIPTS.incrCapped).toContain('if is_new and ARGV[3]') + }) +}) + +describe('RateLimiter.fixedWindow', () => { + + it('returns allowed=true when count is at or below limit', async () => { + const client = makeClient([3, 30]) + const limiter = makeRateLimiter(client) + const result = await limiter.fixedWindow('k', 5, 30) + expect(result.allowed).toBe(true) + expect(result.count).toBe(3) + expect(result.remaining).toBe(2) + }) + + it('returns allowed=false when count exceeds limit', async () => { + const client = makeClient([6, 30]) + const limiter = makeRateLimiter(client) + const result = await limiter.fixedWindow('k', 5, 30) + expect(result.allowed).toBe(false) + expect(result.remaining).toBe(0) + }) + + it('Lua script resets counter to 1 on TTL-loss (SET guard present in source)', () => { + expect(KV_LUA_SCRIPTS.rateLimit).toContain('redis.call("SET", KEYS[1], "1")') + expect(KV_LUA_SCRIPTS.rateLimit).toContain('v = 1') + }) +}) + +describe('RateLimiter.slidingWindow', () => { + + it('returns allowed=true and correct counts when under limit', async () => { + const client = makeClient([1, 2, 3]) + const limiter = makeRateLimiter(client) + const result = await limiter.slidingWindow('k', 5, 60_000) + expect(result.allowed).toBe(true) + expect(result.count).toBe(2) + expect(result.remaining).toBe(3) + }) + + it('returns allowed=false when at limit', async () => { + const client = makeClient([0, 5, 0]) + const limiter = makeRateLimiter(client) + const result = await limiter.slidingWindow('k', 5, 60_000) + expect(result.allowed).toBe(false) + expect(result.remaining).toBe(0) + }) + + it('Lua script calls PEXPIRE on the denied path', () => { + const src = KV_LUA_SCRIPTS.slidingWindow + // PEXPIRE must appear after the if-block (denied path) + const deniedSection = src.slice(src.indexOf('return {0, count, 0}') - 100) + expect(deniedSection).toContain('PEXPIRE') + }) +}) + +describe('RateLimiter.tokenBucket', () => { + + it('rejects negative refillPerSecond', async () => { + const limiter = makeRateLimiter(makeClient([1, '10'])) + await expect(limiter.tokenBucket('k', 10, -1)).rejects.toThrow(RangeError) + }) + + it('accepts refillPerSecond = 0 and reports allowed when tokens available', async () => { + // regression: refill=0 caused EXPIRE key inf on the Redis side — the Lua fix + // guards with `if refill > 0 then ... else ttl = math.ceil(capacity) + 1 end` + const client = makeClient([1, '9']) + const limiter = makeRateLimiter(client) + const result = await limiter.tokenBucket('no-refill', 10, 0, 1) + expect(result.allowed).toBe(true) + expect(result.tokens).toBe(9) + expect(client.evalSha).toHaveBeenCalledOnce() + }) + + it('accepts refillPerSecond = 0 and reports denied when bucket empty', async () => { + const client = makeClient([0, '0']) + const limiter = makeRateLimiter(client) + const result = await limiter.tokenBucket('no-refill', 10, 0, 1) + expect(result.allowed).toBe(false) + expect(result.tokens).toBe(0) + }) + + it('forwards capacity, refill, cost to the script arguments', async () => { + const client = makeClient([1, '4']) + const limiter = makeRateLimiter(client) + await limiter.tokenBucket('k', 5, 0, 1) + const call = client.evalSha.mock.calls[0] + const args: string[] = call[1].arguments + expect(args[0]).toBe('5') // capacity + expect(args[1]).toBe('0') // refillPerSecond + expect(args[3]).toBe('1') // cost + }) +}) diff --git a/node/test/kv/SubscriberPool.test.ts b/node/test/kv/SubscriberPool.test.ts new file mode 100644 index 00000000..4792eca1 --- /dev/null +++ b/node/test/kv/SubscriberPool.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi } from 'vitest' +import { SubscriberPool } from '../../src/kv/SubscriberPool' + +type RawHandler = (message: Buffer, channel: Buffer) => void + +function makeFakeClient() { + const listeners: Record void>> = {} + const subscriptions = new Map() + let open = true + + const client = { + get isOpen() { return open }, + connect: vi.fn(async () => { open = true }), + quit: vi.fn(async () => { open = false }), + on: vi.fn((event: string, cb: () => void) => { + if (!listeners[event]) listeners[event] = [] + listeners[event].push(cb) + return client + }), + subscribe: vi.fn(async (channel: string, listener: RawHandler, _buf: true) => { + subscriptions.set(channel, listener) + }), + unsubscribe: vi.fn(async (channel: string) => { + subscriptions.delete(channel) + }), + emit(event: string) { + for (const cb of listeners[event] ?? []) cb() + }, + getSubscription(channel: string) { return subscriptions.get(channel) }, + hasSubscription(channel: string) { return subscriptions.has(channel) }, + simulateDisconnect() { open = false }, + } + return client +} + +describe('SubscriberPool', () => { + + it('subscribes and delivers messages', async () => { + const fakeClient = makeFakeClient() + const pool = new SubscriberPool(() => fakeClient as never) + + const received: Buffer[] = [] + await pool.subscribeRaw('ch', (msg) => received.push(msg)) + + const listener = fakeClient.getSubscription('ch')! + listener(Buffer.from('hello'), Buffer.from('ch')) + + expect(received).toHaveLength(1) + expect(received[0].toString()).toBe('hello') + }) + + it('resubscribes all channels on reconnect (ready event)', async () => { + const fakeClient = makeFakeClient() + const pool = new SubscriberPool(() => fakeClient as never) + + await pool.subscribeRaw('a', vi.fn()) + await pool.subscribeRaw('b', vi.fn()) + + expect(fakeClient.subscribe).toHaveBeenCalledTimes(2) + + // Simulate drop then reconnect + fakeClient.simulateDisconnect() + fakeClient.subscribe.mockClear() + + // Trigger the ready event (as redis client would on reconnect) + fakeClient.emit('ready') + + expect(fakeClient.subscribe).toHaveBeenCalledTimes(2) + expect(fakeClient.hasSubscription('a')).toBe(true) + expect(fakeClient.hasSubscription('b')).toBe(true) + }) + + it('uses this.connection (not stale client) for unsubscribe after reconnect', async () => { + let clientCount = 0 + const clients: ReturnType[] = [] + + const pool = new SubscriberPool(() => { + const c = makeFakeClient() + clients.push(c) + clientCount++ + return c as never + }) + + const sub = await pool.subscribeRaw('ch', vi.fn()) + const firstClient = clients[0] + expect(firstClient.subscribe).toHaveBeenCalledWith('ch', expect.any(Function), true) + + // Force pool to rebuild connection on next ensureConnection + firstClient.simulateDisconnect() + // Pool-level close to reset connection reference + // (simulating a full reconnect by clearing the pool's connection) + await pool.close() + + // Second subscription will create a new client + const handler2 = vi.fn() + await pool.subscribeRaw('ch', handler2) + expect(clients).toHaveLength(2) + const secondClient = clients[1] + + // Now close the original subscription — must call unsubscribe on the live connection + // (the second client), not on the first (stale) one + // The original sub's close() should reference this.connection, which is secondClient + await sub.close() + + expect(firstClient.unsubscribe).not.toHaveBeenCalled() + // handler2 is still registered so the channel is not unsubscribed yet + expect(secondClient.unsubscribe).not.toHaveBeenCalled() + }) + + it('concurrent subscribeRaw during last-subscription teardown does not lose its subscription', async () => { + const fakeClient = makeFakeClient() + const pool = new SubscriberPool(() => fakeClient as never) + + const handlerA = vi.fn() + const handlerB = vi.fn() + + const subA = await pool.subscribeRaw('ch', handlerA) + + // Start closing A and starting B concurrently. + // subA.close() will try to teardown the connection when entries reach 0. + // subscribeRaw('ch', handlerB) must complete successfully despite the race. + const [, subB] = await Promise.all([ + subA.close(), + pool.subscribeRaw('ch', handlerB), + ]) + + // B's subscription must be registered + expect(fakeClient.hasSubscription('ch')).toBe(true) + + // The connection must not have been quit while B is active + expect(fakeClient.quit).not.toHaveBeenCalled() + + // Messages must reach handler B + const listener = fakeClient.getSubscription('ch')! + listener(Buffer.from('msg'), Buffer.from('ch')) + expect(handlerB).toHaveBeenCalledTimes(1) + + // Clean up + await subB.close() + }) + + it('closes connection when last subscription is removed and no pending subscribes', async () => { + const fakeClient = makeFakeClient() + const pool = new SubscriberPool(() => fakeClient as never) + + const sub = await pool.subscribeRaw('ch', vi.fn()) + await sub.close() + + expect(fakeClient.quit).toHaveBeenCalledTimes(1) + }) + + it('two handlers on same channel share one redis subscription', async () => { + const fakeClient = makeFakeClient() + const pool = new SubscriberPool(() => fakeClient as never) + + const h1 = vi.fn() + const h2 = vi.fn() + const sub1 = await pool.subscribeRaw('ch', h1) + const sub2 = await pool.subscribeRaw('ch', h2) + + expect(fakeClient.subscribe).toHaveBeenCalledTimes(1) + + const listener = fakeClient.getSubscription('ch')! + listener(Buffer.from('x'), Buffer.from('ch')) + expect(h1).toHaveBeenCalledTimes(1) + expect(h2).toHaveBeenCalledTimes(1) + + await sub1.close() + expect(fakeClient.unsubscribe).not.toHaveBeenCalled() + await sub2.close() + expect(fakeClient.unsubscribe).toHaveBeenCalledWith('ch') + }) + + it('ensureConnection rebuilds when existing connection is not open', async () => { + let callCount = 0 + const clients: ReturnType[] = [] + const pool = new SubscriberPool(() => { + callCount++ + const c = makeFakeClient() + clients.push(c) + return c as never + }) + + await pool.subscribeRaw('ch', vi.fn()) + expect(callCount).toBe(1) + + // Drain the pool so connection is cleaned up + const sub = await pool.subscribeRaw('ch2', vi.fn()) + // Close all to null out connection + await pool.close() + + // Connection is now null; next subscribeRaw must create a new client + await pool.subscribeRaw('ch', vi.fn()) + expect(callCount).toBe(2) + }) + +}) diff --git a/node/test/kv/ValueStore.test.ts b/node/test/kv/ValueStore.test.ts new file mode 100644 index 00000000..4cf781a6 --- /dev/null +++ b/node/test/kv/ValueStore.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from 'vitest' +import { ValueStore } from '../../src/kv/ValueStore' +import { Locker } from '../../src/kv/Locker' +import { KeyBuilder, KV_KEY_SEPARATOR } from '../../src/kv/keys' +import { LuaScriptCache } from '../../src/kv/scripts' + +const serializer = { + encode: (_type: string, msg: object): Uint8Array => + new TextEncoder().encode(JSON.stringify(msg)), + decode: (_type: string, buf: Uint8Array): unknown => + JSON.parse(new TextDecoder().decode(buf)), +} + +function makeInMemClient() { + const dataStore = new Map() + const lockStore = new Map() // lockKey → token + + const evalSha = vi.fn(async (_sha: string, opts: { keys: string[]; arguments: string[] }) => { + const [key] = opts.keys + const [token] = opts.arguments + if (opts.arguments.length === 1) { + if (lockStore.get(key) === token) { lockStore.delete(key); return 1 } + return 0 + } + return lockStore.get(key) === token ? 1 : 0 + }) + + const client = { + set: vi.fn(async (key: string, value: string | Buffer, opts?: Record) => { + if (opts?.NX) { + if (lockStore.has(key)) return null + lockStore.set(key, String(value)) + return 'OK' + } + dataStore.set(key, value instanceof Buffer ? value : Buffer.from(String(value))) + return 'OK' + }), + evalSha, + eval: vi.fn(async (_script: string, opts: { keys: string[]; arguments: string[] }) => + evalSha('', opts), + ), + lPush: vi.fn(), + publish: vi.fn(), + get: vi.fn(async (key: string) => dataStore.get(key) ?? null), + } + + const bufferClient = { + get: vi.fn(async (key: string) => dataStore.get(key) ?? null), + set: vi.fn(async (key: string, value: Buffer) => { + dataStore.set(key, value) + return 'OK' + }), + mGet: vi.fn(async () => []), + rPop: vi.fn(async () => null), + brPop: vi.fn(async () => null), + hGetAll: vi.fn(async () => ({})), + eval: vi.fn(async () => null), + evalSha: vi.fn(async () => null), + } + + return { client, bufferClient, dataStore, lockStore } +} + +function makeValueStore() { + const { client, bufferClient } = makeInMemClient() + const keys = new KeyBuilder('test', KV_KEY_SEPARATOR) + const scripts = new LuaScriptCache() + const locker = new Locker(client as never, keys, scripts) + const vs = new ValueStore( + client as never, + keys, + scripts, + serializer as never, + undefined, + bufferClient, + locker, + ) + return { vs, client, bufferClient } +} + +describe('ValueStore.rememberValue', () => { + + it('returns the cached value on a warm key without calling the factory', async () => { + const { vs } = makeValueStore() + await vs.setValue('T', 'k', { n: 1 }, { EX: 60 }) + let calls = 0 + const result = await vs.rememberValue('T', 'k', 60, () => { calls++; return { n: 99 } }) + expect(calls).toBe(0) + expect(result).toEqual({ n: 1 }) + }) + + it('calls factory once and stores result on a cold key', async () => { + const { vs } = makeValueStore() + let calls = 0 + const result = await vs.rememberValue('T', 'cold', 60, async () => { + calls++ + return { x: 42 } + }) + expect(calls).toBe(1) + expect(result).toEqual({ x: 42 }) + // Subsequent call should hit cache + const again = await vs.rememberValue('T', 'cold', 60, () => { calls++; return { x: 0 } }) + expect(calls).toBe(1) + expect(again).toEqual({ x: 42 }) + }) + + it('fires factory exactly once with N concurrent callers on a cold key', async () => { + const { vs } = makeValueStore() + const N = 5 + let factoryCount = 0 + const factory = async (): Promise<{ result: string }> => { + factoryCount++ + return { result: 'computed' } + } + + const results = await Promise.all( + Array.from({ length: N }, () => vs.rememberValue('T', 'shared', 60, factory)), + ) + + expect(factoryCount).toBe(1) + expect(results).toHaveLength(N) + for (const r of results) { + expect(r).toEqual({ result: 'computed' }) + } + }, 10_000) + + it('skips factory inside lock when a concurrent caller already populated the key', async () => { + const { vs } = makeValueStore() + + let getCallCount = 0 + const originalGet = vs.getValue.bind(vs) + vi.spyOn(vs, 'getValue').mockImplementation(async (type, key) => { + getCallCount++ + // First call (pre-lock check): cold miss + if (getCallCount === 1) return null + // Second call (recheck inside lock): value already present + if (getCallCount === 2) { + await vs.setValue(type, key, { prefilled: true }, { EX: 60 }) + return originalGet(type, key) + } + return originalGet(type, key) + }) + + let factoryCount = 0 + const result = await vs.rememberValue('T', 'race', 60, () => { + factoryCount++ + return { prefilled: false } + }) + + expect(factoryCount).toBe(0) + expect(result).toEqual({ prefilled: true }) + }) + +}) diff --git a/node/test/kv/Versioned.test.ts b/node/test/kv/Versioned.test.ts new file mode 100644 index 00000000..07514919 --- /dev/null +++ b/node/test/kv/Versioned.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest' +import { Versioned } from '../../src/kv/Versioned' +import { KeyBuilder, KV_KEY_SEPARATOR } from '../../src/kv/keys' +import { LuaScriptCache } from '../../src/kv/scripts' +import { KVServiceError } from '../../src/errors' + +// JavaScript re-implementation of LUA_VERSIONED_SET used to drive the in-memory +// mock. Kept in sync with the Lua in scripts.ts so unit tests exercise the same +// branching logic without requiring a live Redis connection. +function runVersionedSetLogic( + hashStore: Map>, + opts: { keys: string[]; arguments: string[] }, +): [number, number] { + const key = opts.keys[0] + const [versionArg, data] = opts.arguments + + const hash = hashStore.get(key) + const cur = hash?.get('version') ?? null + const marker = hash?.get('__vset') ?? null + const keyExists = hash !== undefined && hash.size > 0 ? 1 : 0 + + // Foreign hash: key exists but has neither our version field nor our marker + if (cur === null && marker === null && keyExists === 1) { + return [0, -1] + } + + // Version mismatch + if (cur !== null && Number(cur) !== Number(versionArg)) { + return [0, Number(cur)] + } + + // Non-zero expected version on a non-existent key + if (cur === null && Number(versionArg) !== 0) { + return [0, 0] + } + + const nextVersion = Number(versionArg) + 1 + if (!hashStore.has(key)) hashStore.set(key, new Map()) + const h = hashStore.get(key)! + h.set('version', String(nextVersion)) + h.set('data', data) + h.set('__vset', '1') + + return [1, nextVersion] +} + +function makeVersionedStore() { + const hashStore = new Map>() + + const evalImpl = async (_: string, opts: { keys: string[]; arguments: string[] }) => + runVersionedSetLogic(hashStore, opts) + + const client = { + evalSha: evalImpl, + eval: evalImpl, + hGetAll: async (key: string): Promise> => { + const hash = hashStore.get(key) + if (!hash || hash.size === 0) return {} + return Object.fromEntries(hash.entries()) + }, + } + + const bufferClient = { + hGetAll: async (key: string): Promise> => { + const hash = hashStore.get(key) + if (!hash || hash.size === 0) return {} + const result: Record = {} + for (const [k, v] of hash.entries()) result[k] = Buffer.from(v) + return result + }, + } + + const keys = new KeyBuilder('test', KV_KEY_SEPARATOR) + const scripts = new LuaScriptCache() + const versioned = new Versioned( + client as never, + keys, + scripts, + undefined, + bufferClient, + ) + + function setForeignHash(key: string, fields: Record): void { + const fullKey = keys.build(key) + hashStore.set(fullKey, new Map(Object.entries(fields))) + } + + return { versioned, hashStore, keys, setForeignHash } +} + +describe('Versioned.set — foreign hash guard', () => { + + it('refuses CAS with expectedVersion=0 when a foreign hash already occupies the key', async () => { + const { versioned, setForeignHash } = makeVersionedStore() + setForeignHash('item:1', { someOtherField: 'someValue' }) + + await expect( + versioned.set('item:1', 0, 'payload'), + ).rejects.toThrow(KVServiceError) + + await expect( + versioned.set('item:1', 0, 'payload'), + ).rejects.toThrow(/foreign hash/) + }) + + it('refuses CAS with any expectedVersion when a foreign hash occupies the key', async () => { + const { versioned, setForeignHash } = makeVersionedStore() + setForeignHash('item:2', { unrelated: 'data', count: '5' }) + + await expect( + versioned.set('item:2', 3, 'payload'), + ).rejects.toThrow(KVServiceError) + }) + + it('succeeds on a brand-new key (expectedVersion=0)', async () => { + const { versioned } = makeVersionedStore() + const result = await versioned.set('item:new', 0, 'hello') + expect(result.ok).toBe(true) + expect(result.version).toBe(1) + }) + + it('sets __vset marker on first write so subsequent CAS is accepted', async () => { + const { versioned, hashStore, keys } = makeVersionedStore() + await versioned.set('item:seq', 0, 'v0') + const hash = hashStore.get(keys.build('item:seq')) + expect(hash?.get('__vset')).toBe('1') + + const next = await versioned.set('item:seq', 1, 'v1') + expect(next.ok).toBe(true) + expect(next.version).toBe(2) + }) + + it('returns ok=false with current version on a version mismatch (not a foreign-hash error)', async () => { + const { versioned } = makeVersionedStore() + await versioned.set('item:v', 0, 'init') + const result = await versioned.set('item:v', 99, 'bad') + expect(result.ok).toBe(false) + expect(result.version).toBe(1) + }) + + it('returns ok=false when expectedVersion != 0 but key does not exist', async () => { + const { versioned } = makeVersionedStore() + const result = await versioned.set('item:ghost', 5, 'payload') + expect(result.ok).toBe(false) + expect(result.version).toBe(0) + }) + + it('is backward-compatible: existing versioned key without __vset marker is not treated as foreign', async () => { + const { versioned, hashStore, keys } = makeVersionedStore() + // Simulate a key written by the old code (has version but no __vset) + hashStore.set(keys.build('item:old'), new Map([ + ['version', '3'], + ['data', 'legacy'], + ])) + + const result = await versioned.set('item:old', 3, 'updated') + expect(result.ok).toBe(true) + expect(result.version).toBe(4) + }) + +}) diff --git a/node/test/lazySharp.test.ts b/node/test/lazySharp.test.ts new file mode 100644 index 00000000..7da2d4c7 --- /dev/null +++ b/node/test/lazySharp.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from 'vitest' + +describe('lazySharp - missing peer', () => { + it('wraps missing-module error with friendly install message', async () => { + // Reset module registry so lazySharp gets a fresh instance (cached = null, pending = null) + vi.resetModules() + // Mock sharp to simulate it not being installed + vi.doMock('sharp', () => { + throw new Error("Cannot find module 'sharp'") + }) + + const { loadSharp } = await import('../src/internal/lazySharp') + + await expect(loadSharp()).rejects.toThrow(/install sharp/) + + vi.doUnmock('sharp') + vi.resetModules() + }) +}) diff --git a/node/test/oauth2/callback.test.ts b/node/test/oauth2/callback.test.ts new file mode 100644 index 00000000..0912d27a --- /dev/null +++ b/node/test/oauth2/callback.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { verifyCallback } from '../../src/oauth2/callback' +import { OAuth2CallbackError } from '../../src/errors' + +describe('verifyCallback', () => { + + it('passes when stored and received are identical', () => { + expect(() => verifyCallback({ stored: 'abc123', received: 'abc123' })).not.toThrow() + }) + + it('throws OAuth2CallbackError on value mismatch (same length)', () => { + expect(() => verifyCallback({ stored: 'aaaaaa', received: 'bbbbbb' })) + .toThrow(OAuth2CallbackError) + }) + + it('throws OAuth2CallbackError on length mismatch', () => { + expect(() => verifyCallback({ stored: 'short', received: 'longer-value' })) + .toThrow(OAuth2CallbackError) + }) + + it('error message identifies state mismatch', () => { + expect(() => verifyCallback({ stored: 'x', received: 'y' })) + .toThrow('state mismatch') + }) + + it('passes with long opaque state strings', () => { + const state = 'eyJhbGciOiJIUzI1NiJ9.dGVzdA.abc123XYZ' + expect(() => verifyCallback({ stored: state, received: state })).not.toThrow() + }) + + it('throws when received has one extra character', () => { + expect(() => verifyCallback({ stored: 'state123', received: 'state1234' })) + .toThrow(OAuth2CallbackError) + }) + + it('throws on empty vs non-empty', () => { + expect(() => verifyCallback({ stored: '', received: 'x' })) + .toThrow(OAuth2CallbackError) + }) + + it('passes when both are empty strings', () => { + expect(() => verifyCallback({ stored: '', received: '' })).not.toThrow() + }) +}) diff --git a/node/test/oauth2/flow.test.ts b/node/test/oauth2/flow.test.ts index c5390a56..500fa1bc 100644 --- a/node/test/oauth2/flow.test.ts +++ b/node/test/oauth2/flow.test.ts @@ -3,6 +3,64 @@ import { buildAuthorizeURL, exchangeCode, refreshToken, revokeToken, fetchUserin import { defineOAuth2Provider } from '../../src/oauth2/types' import { OAuth2TokenError, OAuth2ProtocolError } from '../../src/errors' +describe('defineOAuth2Provider — PKCE defaults and warnings', () => { + + it('defaults pkceMethod to S256 for public clients (no clientSecret)', () => { + const provider = defineOAuth2Provider({ + authorizationEndpoint: 'https://idp.test/auth', + tokenEndpoint: 'https://idp.test/token', + clientId: 'pub-client' + }) + expect(provider.pkceMethod).toBe('S256') + }) + + it('does not override pkceMethod when caller sets it explicitly for a public client', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const provider = defineOAuth2Provider({ + authorizationEndpoint: 'https://idp.test/auth', + tokenEndpoint: 'https://idp.test/token', + clientId: 'pub-client', + pkceMethod: 'plain' + }) + expect(provider.pkceMethod).toBe('plain') + vi.restoreAllMocks() + }) + + it('does not set pkceMethod by default for confidential clients', () => { + const provider = defineOAuth2Provider({ + authorizationEndpoint: 'https://idp.test/auth', + tokenEndpoint: 'https://idp.test/token', + clientId: 'cid', + clientSecret: 'cs' + }) + expect(provider.pkceMethod).toBeUndefined() + }) + + it('emits console.warn when plain pkceMethod is configured', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + defineOAuth2Provider({ + authorizationEndpoint: 'https://idp.test/auth', + tokenEndpoint: 'https://idp.test/token', + clientId: 'cid', + pkceMethod: 'plain' + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('plain')) + vi.restoreAllMocks() + }) + + it('does not emit console.warn for S256', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + defineOAuth2Provider({ + authorizationEndpoint: 'https://idp.test/auth', + tokenEndpoint: 'https://idp.test/token', + clientId: 'cid', + pkceMethod: 'S256' + }) + expect(warn).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) +}) + const baseProvider = defineOAuth2Provider({ authorizationEndpoint: 'https://idp.test/authorize', tokenEndpoint: 'https://idp.test/token', @@ -103,6 +161,26 @@ describe('exchangeCode', () => { await expect(exchangeCode(baseProvider, { code: 'c', redirectUri: 'https://app.test/cb' }, { fetchImpl })).rejects.toBeInstanceOf(OAuth2TokenError) }) + it('upstreamBody contains only error/error_description — no extra fields', async () => { + const fetchImpl = mockFetch(400, { + error: 'invalid_grant', + error_description: 'bad code', + access_token: 'leaked-token', + client_secret: 'leaked-secret' + }) + let err: OAuth2TokenError | undefined + try { + await exchangeCode(baseProvider, { code: 'c', redirectUri: 'https://app.test/cb' }, { fetchImpl }) + } catch (e) { + err = e as OAuth2TokenError + } + expect(err).toBeInstanceOf(OAuth2TokenError) + const body = err!.upstreamBody as Record + expect(body).toEqual({ error: 'invalid_grant', error_description: 'bad code' }) + expect(body).not.toHaveProperty('access_token') + expect(body).not.toHaveProperty('client_secret') + }) + it('throws OAuth2ProtocolError when access_token missing', async () => { const fetchImpl = mockFetch(200, { token_type: 'Bearer' }) await expect(exchangeCode(baseProvider, { code: 'c', redirectUri: 'https://app.test/cb' }, { fetchImpl })).rejects.toBeInstanceOf(OAuth2ProtocolError) @@ -148,6 +226,21 @@ describe('revokeToken', () => { }) }) +describe('upstreamBody redaction', () => { + + it('non-JSON error response yields undefined upstreamBody', async () => { + const fetchImpl = vi.fn(async () => new Response('Service Unavailable', { status: 503 })) as any + let err: OAuth2TokenError | undefined + try { + await exchangeCode(baseProvider, { code: 'c', redirectUri: 'https://app.test/cb' }, { fetchImpl }) + } catch (e) { + err = e as OAuth2TokenError + } + expect(err).toBeInstanceOf(OAuth2TokenError) + expect(err!.upstreamBody).toBeUndefined() + }) +}) + describe('fetchUserinfo', () => { it('GETs with bearer token', async () => { diff --git a/node/test/oauth2/grants.test.ts b/node/test/oauth2/grants.test.ts index 492c21ad..ae1a9e91 100644 --- a/node/test/oauth2/grants.test.ts +++ b/node/test/oauth2/grants.test.ts @@ -43,6 +43,26 @@ describe('clientCredentialsToken', () => { const fetchImpl = queue([{ status: 401, body: { error: 'invalid_client' } }]) await expect(clientCredentialsToken(provider, undefined, { fetchImpl })).rejects.toBeInstanceOf(OAuth2TokenError) }) + + it('upstreamBody is redacted — extra fields stripped', async () => { + const fetchImpl = queue([{ status: 401, body: { + error: 'invalid_client', + error_description: 'bad creds', + client_secret: 'leaked', + access_token: 'leaked' + } }]) + let err: OAuth2TokenError | undefined + try { + await clientCredentialsToken(provider, undefined, { fetchImpl }) + } catch (e) { + err = e as OAuth2TokenError + } + expect(err).toBeInstanceOf(OAuth2TokenError) + const body = err!.upstreamBody as Record + expect(body).toEqual({ error: 'invalid_client', error_description: 'bad creds' }) + expect(body).not.toHaveProperty('client_secret') + expect(body).not.toHaveProperty('access_token') + }) }) describe('requestDeviceCode', () => { diff --git a/node/test/oauth2/oidc.test.ts b/node/test/oauth2/oidc.test.ts index f83fc629..eb5f62e5 100644 --- a/node/test/oauth2/oidc.test.ts +++ b/node/test/oauth2/oidc.test.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto' import { verifyIdToken, fetchOIDCDiscovery, clearJwksCache, clearDiscoveryCache, oidcProvider } from '../../src/oauth2/oidc' -import { OIDCVerificationError } from '../../src/errors' +import { OIDCVerificationError, OAuth2ProtocolError } from '../../src/errors' const ISSUER = 'https://idp.test' const AUDIENCE = 'cid' @@ -88,6 +88,22 @@ describe('verifyIdToken', () => { await expect(verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks, allowedAlgorithms: ['ES256'] })).rejects.toBeInstanceOf(OIDCVerificationError) }) + it('rejects symmetric algorithm HS256 before any verification', async () => { + const token = await signToken({}) + await expect(verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks, allowedAlgorithms: ['HS256'] })).rejects.toThrow('symmetric/none algorithms are not allowed for ID tokens') + }) + + it('rejects none algorithm before any verification', async () => { + const token = await signToken({}) + await expect(verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks, allowedAlgorithms: ['none'] })).rejects.toThrow('symmetric/none algorithms are not allowed for ID tokens') + }) + + it('accepts RS256 in allowedAlgorithms', async () => { + const token = await signToken({}) + const verified = await verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks, allowedAlgorithms: ['RS256'] }) + expect(verified.payload.iss).toBe(ISSUER) + }) + it('matches nonce', async () => { const token = await signToken({ nonce: 'n1' }) const verified = await verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { nonce: 'n1' }) @@ -113,6 +129,52 @@ describe('verifyIdToken', () => { await expect(verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { accessToken: 'at-test' })).rejects.toBeInstanceOf(OIDCVerificationError) }) + it('rejects when accessToken provided but at_hash absent', async () => { + const token = await signToken({}) + await expect(verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { accessToken: 'at-test' })).rejects.toThrow('at_hash required but absent') + }) + + it('checks c_hash', async () => { + const authorizationCode = 'code-test' + const digest = createHash('sha256').update(authorizationCode).digest() + const c_hash = digest.subarray(0, 16).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + const token = await signToken({ c_hash }) + const verified = await verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { authorizationCode }) + expect(verified.payload.c_hash).toBe(c_hash) + }) + + it('rejects bad c_hash', async () => { + const token = await signToken({ c_hash: 'wrong' }) + await expect(verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { authorizationCode: 'code-test' })).rejects.toBeInstanceOf(OIDCVerificationError) + }) + + it('rejects when authorizationCode provided but c_hash absent', async () => { + const token = await signToken({}) + await expect(verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { authorizationCode: 'code-test' })).rejects.toThrow('c_hash required but absent') + }) + + it('rejects at_hash that has the correct length but wrong value (timing-safe comparison)', async () => { + // Build the correct at_hash for 'at-test', then flip one character to produce a + // same-length string that must be rejected — exercises the timingSafeEqual path. + const accessToken = 'at-test' + const digest = createHash('sha256').update(accessToken).digest() + const correctHash = digest.subarray(0, 16).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + // Produce a same-length but different value by corrupting the first char + const wrongChar = correctHash[0] === 'A' ? 'B' : 'A' + const wrongHash = wrongChar + correctHash.slice(1) + expect(wrongHash.length).toBe(correctHash.length) + const token = await signToken({ at_hash: wrongHash }) + await expect(verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { accessToken })).rejects.toThrow('at_hash mismatch') + }) + + it('rejects nonce with same length but different value (timing-safe comparison)', async () => { + const nonce = 'aaaaaaaaaaaaaaaa' + const wrongNonce = 'aaaaaaaaaaaaaaab' + expect(nonce.length).toBe(wrongNonce.length) + const token = await signToken({ nonce }) + await expect(verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { nonce: wrongNonce })).rejects.toThrow('nonce mismatch') + }) + it('enforces requiredAmr', async () => { const token = await signToken({ amr: ['pwd', 'mfa'] }) const verified = await verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { requiredAmr: ['mfa'] }) @@ -125,30 +187,132 @@ describe('verifyIdToken', () => { const oldToken = await signToken({ auth_time: Math.floor(Date.now() / 1000) - 3600 }) await expect(verifyIdToken(oldToken, { issuer: ISSUER, audience: AUDIENCE, jwks: localJwks }, { maxAgeSeconds: 60 })).rejects.toBeInstanceOf(OIDCVerificationError) }) + + it('invokes custom fetchImpl from http options when jwksUri is used', async () => { + const jwks = { keys: [publicJwk] } + const customFetch = mockJwksFetch(jwks) + const token = await signToken({}) + clearJwksCache() + const result = await verifyIdToken(token, { + issuer: ISSUER, + audience: AUDIENCE, + jwksUri: `${ISSUER}/jwks`, + http: { fetchImpl: customFetch } + }) + expect(customFetch).toHaveBeenCalled() + expect(result.payload.iss).toBe(ISSUER) + }) + + it('uses its own fetchImpl and does not share the global cache with other callers', async () => { + const jwks = { keys: [publicJwk] } + const customFetch1 = mockJwksFetch(jwks) + const customFetch2 = mockJwksFetch(jwks) + const token = await signToken({}) + clearJwksCache() + await Promise.all([ + verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwksUri: `${ISSUER}/jwks`, http: { fetchImpl: customFetch1 } }), + verifyIdToken(token, { issuer: ISSUER, audience: AUDIENCE, jwksUri: `${ISSUER}/jwks`, http: { fetchImpl: customFetch2 } }) + ]) + expect(customFetch1).toHaveBeenCalled() + expect(customFetch2).toHaveBeenCalled() + }) + + it('enforces timeoutMs: aborts JWKS fetch that exceeds the timeout', async () => { + const slowFetch = vi.fn((_url: string, init: RequestInit) => { + return new Promise((_resolve, reject) => { + if (init.signal) { + init.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))) + } + }) + }) as any + const token = await signToken({}) + clearJwksCache() + await expect( + verifyIdToken(token, { + issuer: ISSUER, + audience: AUDIENCE, + jwksUri: `${ISSUER}/jwks`, + http: { fetchImpl: slowFetch, timeoutMs: 50 } + }) + ).rejects.toThrow() + expect(slowFetch).toHaveBeenCalled() + }) }) describe('fetchOIDCDiscovery', () => { + const makeDiscoveryDoc = (issuer: string = ISSUER) => ({ + issuer, + authorization_endpoint: `${issuer}/auth`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/jwks`, + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'] + }) + const fetchDiscovery = (doc: any) => vi.fn(async () => new Response(JSON.stringify(doc), { status: 200, headers: { 'Content-Type': 'application/json' } })) as any it('fetches and parses discovery document', async () => { - const doc = { - issuer: ISSUER, - authorization_endpoint: `${ISSUER}/auth`, - token_endpoint: `${ISSUER}/token`, - jwks_uri: `${ISSUER}/jwks`, - response_types_supported: ['code'], - subject_types_supported: ['public'], - id_token_signing_alg_values_supported: ['RS256'] - } + const doc = makeDiscoveryDoc() const fetchImpl = fetchDiscovery(doc) const result = await fetchOIDCDiscovery(ISSUER, { fetchImpl, cacheTtlMs: 0 }) expect(result.issuer).toBe(ISSUER) expect(fetchImpl.mock.calls[0][0]).toBe(`${ISSUER}/.well-known/openid-configuration`) }) + + it('rejects when discovery issuer does not match requested issuer', async () => { + const doc = makeDiscoveryDoc('https://evil.test') + const fetchImpl = fetchDiscovery(doc) + await expect(fetchOIDCDiscovery(ISSUER, { fetchImpl, cacheTtlMs: 0 })).rejects.toBeInstanceOf(OAuth2ProtocolError) + }) + + it('accepts matching issuer without trailing slash', async () => { + const doc = makeDiscoveryDoc() + const fetchImpl = fetchDiscovery(doc) + const result = await fetchOIDCDiscovery(`${ISSUER}/`, { fetchImpl, cacheTtlMs: 0 }) + expect(result.issuer).toBe(ISSUER) + }) + + it('concurrent callers with different fetchImpl each use their own implementation', async () => { + const doc = makeDiscoveryDoc() + const fetchImpl1 = fetchDiscovery(doc) + const fetchImpl2 = fetchDiscovery(doc) + const [r1, r2] = await Promise.all([ + fetchOIDCDiscovery(ISSUER, { fetchImpl: fetchImpl1 }), + fetchOIDCDiscovery(ISSUER, { fetchImpl: fetchImpl2 }) + ]) + expect(fetchImpl1).toHaveBeenCalledOnce() + expect(fetchImpl2).toHaveBeenCalledOnce() + expect(r1.issuer).toBe(ISSUER) + expect(r2.issuer).toBe(ISSUER) + }) + + it('caller with custom fetchImpl is not served a cached result', async () => { + // Populate the shared cache (no fetchImpl = cached path, uses global fetch mock) + const doc = makeDiscoveryDoc() + vi.stubGlobal('fetch', fetchDiscovery(doc)) + await fetchOIDCDiscovery(ISSUER) + + // A subsequent call with a custom fetchImpl must bypass the cache and hit its own impl + const fetchImpl2 = fetchDiscovery(doc) + await fetchOIDCDiscovery(ISSUER, { fetchImpl: fetchImpl2 }) + expect(fetchImpl2).toHaveBeenCalledOnce() + }) + + it('caller with custom headers is not served a cached result', async () => { + const doc = makeDiscoveryDoc() + vi.stubGlobal('fetch', fetchDiscovery(doc)) + await fetchOIDCDiscovery(ISSUER) + + const fetchImpl2 = fetchDiscovery(doc) + vi.stubGlobal('fetch', fetchImpl2) + await fetchOIDCDiscovery(ISSUER, { headers: { Authorization: 'Bearer tenant-token' } }) + expect(fetchImpl2).toHaveBeenCalledOnce() + }) }) describe('oidcProvider', () => { diff --git a/node/test/oauth2/profiles.test.ts b/node/test/oauth2/profiles.test.ts index e91ecbb3..b7aaff56 100644 --- a/node/test/oauth2/profiles.test.ts +++ b/node/test/oauth2/profiles.test.ts @@ -1,18 +1,10 @@ import { describe, it, expect } from 'vitest' import { parseStandardOIDCProfile, parseGitHubProfile, parseDiscordProfile } from '../../src/oauth2/profiles' -import type { OAuth2Tokens } from '../../src/oauth2/types' - -const baseTokens: OAuth2Tokens = { - accessToken: 'at', - tokenType: 'Bearer', - raw: {} -} describe('parseStandardOIDCProfile', () => { it('maps userinfo claims', () => { const profile = parseStandardOIDCProfile({ - tokens: baseTokens, userinfo: { sub: 'u-1', email: 'a@x', email_verified: true, name: 'A', picture: 'https://img.test/a.png' } }) expect(profile.subject).toBe('u-1') @@ -21,16 +13,33 @@ describe('parseStandardOIDCProfile', () => { expect(profile.avatarUrl).toBe('https://img.test/a.png') }) - it('id_token claims override userinfo', () => { + it('idTokenClaims override userinfo', () => { const profile = parseStandardOIDCProfile({ - tokens: { ...baseTokens, raw: { sub: 'tok-sub' } }, + idTokenClaims: { sub: 'tok-sub' }, userinfo: { sub: 'ui-sub' } }) expect(profile.subject).toBe('tok-sub') }) + it('does not trust unverified tokens.raw — only idTokenClaims and userinfo are accepted', () => { + // Passing only idTokenClaims with no email yields no email in the profile; + // callers must pass verified claims explicitly rather than raw token responses. + const profile = parseStandardOIDCProfile({ idTokenClaims: { sub: 'u1' } }) + expect(profile.email).toBeUndefined() + expect(profile.emailVerified).toBeUndefined() + }) + + it('idTokenClaims email takes precedence over userinfo email', () => { + const profile = parseStandardOIDCProfile({ + idTokenClaims: { sub: 'u1', email: 'verified@idp', email_verified: true }, + userinfo: { sub: 'u1', email: 'attacker@evil', email_verified: false } + }) + expect(profile.email).toBe('verified@idp') + expect(profile.emailVerified).toBe(true) + }) + it('throws on missing sub', () => { - expect(() => parseStandardOIDCProfile({ tokens: baseTokens })).toThrow(/missing sub/) + expect(() => parseStandardOIDCProfile({})).toThrow(/missing sub/) }) }) @@ -49,6 +58,27 @@ describe('parseGitHubProfile', () => { expect(profile.name).toBe('Octo Cat') }) + it('falls back to any verified email when no primary verified email exists', () => { + const profile = parseGitHubProfile({ + user: { id: 1, login: 'octo' }, + emails: [ + { email: 'unverified@x', primary: true, verified: false }, + { email: 'verified@x', primary: false, verified: true } + ] + }) + expect(profile.email).toBe('verified@x') + expect(profile.emailVerified).toBe(true) + }) + + it('does not include email when no verified email exists — prevents account-linking takeover', () => { + const profile = parseGitHubProfile({ + user: { id: 1, login: 'octo' }, + emails: [{ email: 'unverified@x', primary: true, verified: false }] + }) + expect(profile.email).toBeUndefined() + expect(profile.emailVerified).toBeUndefined() + }) + it('falls back to login when name missing', () => { const profile = parseGitHubProfile({ user: { id: 1, login: 'foo' }, diff --git a/node/test/oauth2/random.test.ts b/node/test/oauth2/random.test.ts index bfa00638..9d896914 100644 --- a/node/test/oauth2/random.test.ts +++ b/node/test/oauth2/random.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { createHash } from 'node:crypto' import { generatePKCE, generateState, generateNonce } from '../../src/oauth2/random' @@ -22,9 +22,26 @@ describe('generatePKCE', () => { }) it('plain mode: challenge equals verifier', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) const pair = generatePKCE('plain') expect(pair.method).toBe('plain') expect(pair.codeChallenge).toBe(pair.codeVerifier) + vi.restoreAllMocks() + }) + + it('emits a console.warn when plain method is used', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + generatePKCE('plain') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('plain')) + vi.restoreAllMocks() + }) + + it('does not emit console.warn for S256', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + generatePKCE('S256') + generatePKCE() + expect(warn).not.toHaveBeenCalled() + vi.restoreAllMocks() }) it('rejects unknown method', () => { diff --git a/node/test/utils/jsonSchema.test.ts b/node/test/utils/jsonSchema.test.ts new file mode 100644 index 00000000..44e4a353 --- /dev/null +++ b/node/test/utils/jsonSchema.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { deriveJsonSchema } from '../../src/utils/sanitize/jsonSchema' +import { FieldSchema } from '../../src/utils/sanitize/types' + +interface Order { + tags: string[] + meta: Record +} + +const schema: FieldSchema = { + tags: { + type: 'array', + min: 1, + max: 10, + items: { type: 'string' }, + }, + meta: { + type: 'object', + fields: { + label: { type: 'string' }, + count: { type: 'number' }, + }, + }, +} + +describe('deriveJsonSchema — array bounds', () => { + it('maps min/max to minItems/maxItems in loose mode', () => { + const result = deriveJsonSchema(schema, 'create') + const tags = result.properties?.tags + expect(tags?.minItems).toBe(1) + expect(tags?.maxItems).toBe(10) + }) + + it('maps min/max to minItems/maxItems in strict mode', () => { + const result = deriveJsonSchema(schema, 'create', { strict: true }) + const tags = result.properties?.tags + expect(tags?.minItems).toBe(1) + expect(tags?.maxItems).toBe(10) + }) +}) + +describe('deriveJsonSchema — nested object', () => { + it('emits properties for nested object fields', () => { + const result = deriveJsonSchema(schema, 'create') + const meta = result.properties?.meta + expect(meta?.properties).toBeDefined() + expect(meta?.properties?.label).toEqual({ type: 'string' }) + expect(meta?.properties?.count).toEqual({ type: 'number' }) + }) + + it('strict mode sets additionalProperties:false on nested object', () => { + const result = deriveJsonSchema(schema, 'create', { strict: true }) + const meta = result.properties?.meta + expect(meta?.additionalProperties).toBe(false) + }) + + it('loose mode does not set additionalProperties on nested object', () => { + const result = deriveJsonSchema(schema, 'create') + const meta = result.properties?.meta + expect(meta?.additionalProperties).toBeUndefined() + }) + + it('strict mode also sets additionalProperties:false at root', () => { + const result = deriveJsonSchema(schema, 'create', { strict: true }) + expect(result.additionalProperties).toBe(false) + }) +}) diff --git a/node/test/utils/pagination.test.ts b/node/test/utils/pagination.test.ts new file mode 100644 index 00000000..654e012c --- /dev/null +++ b/node/test/utils/pagination.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { normalizeOffsetLimit } from '../../src/utils/pagination' +import { ValidationError } from '../../src/errors' + +describe('normalizeOffsetLimit', () => { + describe('limit', () => { + it('accepts plain decimal integer', () => { + expect(normalizeOffsetLimit({ limit: 50 }).limit).toBe(50) + }) + + it('accepts string decimal integer', () => { + expect(normalizeOffsetLimit({ limit: '20' as unknown as number }).limit).toBe(20) + }) + + it('rejects hex string in strict mode', () => { + expect(() => normalizeOffsetLimit({ limit: '0x3e8' as unknown as number }, { strict: true })).toThrow(ValidationError) + }) + + it('rejects hex string in non-strict mode (returns default)', () => { + expect(normalizeOffsetLimit({ limit: '0x3e8' as unknown as number }).limit).toBe(25) + }) + + it('rejects scientific notation in strict mode', () => { + expect(() => normalizeOffsetLimit({ limit: '1e2' as unknown as number }, { strict: true })).toThrow(ValidationError) + }) + + it('rejects scientific notation in non-strict mode (returns default)', () => { + expect(normalizeOffsetLimit({ limit: '1e2' as unknown as number }).limit).toBe(25) + }) + + it('clamps to maxLimit', () => { + expect(normalizeOffsetLimit({ limit: 9999 }, { maxLimit: 100 }).limit).toBe(100) + }) + + it('rejects zero in strict mode', () => { + expect(() => normalizeOffsetLimit({ limit: 0 }, { strict: true })).toThrow(ValidationError) + }) + + it('uses defaultLimit when limit absent', () => { + expect(normalizeOffsetLimit({}, { defaultLimit: 10 }).limit).toBe(10) + }) + + it('rejects negative number in strict mode', () => { + expect(() => normalizeOffsetLimit({ limit: -5 }, { strict: true })).toThrow(ValidationError) + }) + }) + + describe('offset', () => { + it('accepts zero offset', () => { + expect(normalizeOffsetLimit({ offset: 0 }).offset).toBe(0) + }) + + it('accepts string decimal offset', () => { + expect(normalizeOffsetLimit({ offset: '100' as unknown as number }).offset).toBe(100) + }) + + it('rejects hex string offset in strict mode', () => { + expect(() => normalizeOffsetLimit({ offset: '0x3e8' as unknown as number }, { strict: true })).toThrow(ValidationError) + }) + + it('rejects hex string offset in non-strict mode (returns 0)', () => { + expect(normalizeOffsetLimit({ offset: '0x3e8' as unknown as number }).offset).toBe(0) + }) + + it('rejects scientific notation offset in strict mode', () => { + expect(() => normalizeOffsetLimit({ offset: '1e2' as unknown as number }, { strict: true })).toThrow(ValidationError) + }) + + it('rejects scientific notation offset in non-strict mode (returns 0)', () => { + expect(normalizeOffsetLimit({ offset: '1e2' as unknown as number }).offset).toBe(0) + }) + + it('caps offset at maxOffset', () => { + expect(normalizeOffsetLimit({ offset: 5000 }, { maxOffset: 1000 }).offset).toBe(1000) + }) + + it('does not cap when maxOffset unset', () => { + expect(normalizeOffsetLimit({ offset: 9999 }).offset).toBe(9999) + }) + + it('rejects negative offset in strict mode', () => { + expect(() => normalizeOffsetLimit({ offset: -1 }, { strict: true })).toThrow(ValidationError) + }) + + it('rejects negative offset in non-strict mode (returns 0)', () => { + expect(normalizeOffsetLimit({ offset: -1 }).offset).toBe(0) + }) + }) +}) diff --git a/node/test/utils/parseFilters.test.ts b/node/test/utils/parseFilters.test.ts index cf2a8ea3..bd22a97f 100644 --- a/node/test/utils/parseFilters.test.ts +++ b/node/test/utils/parseFilters.test.ts @@ -14,62 +14,75 @@ interface Row { describe('parseFilters', () => { + it('throws when neither allowedFields nor schema provided', () => { + expect(() => parseFilters({ email: 'x' })).toThrow('parseFilters: pass allowedFields or schema; [] to allow none') + }) + + it('throws when neither allowedFields nor schema provided even on empty query', () => { + expect(() => parseFilters({})).toThrow('parseFilters: pass allowedFields or schema; [] to allow none') + }) + it('returns {} on empty query', () => { - expect(parseFilters({})).toEqual({}) + expect(parseFilters({}, { allowedFields: [] })).toEqual({}) }) it('emits eq for top-level scalar', () => { - expect(parseFilters({ email: 'foo@bar.com' })).toEqual({ email: 'foo@bar.com' }) + expect(parseFilters({ email: 'foo@bar.com' }, { allowedFields: ['email'] })).toEqual({ email: 'foo@bar.com' }) }) it('emits multiple eq for multiple keys', () => { - expect(parseFilters({ email: 'a', status: 'active' })).toEqual({ email: 'a', status: 'active' }) + expect(parseFilters({ email: 'a', status: 'active' }, { allowedFields: ['email', 'status'] })).toEqual({ email: 'a', status: 'active' }) }) it('emits IN for top-level array', () => { - expect(parseFilters({ ids: ['1', '2'] })).toEqual({ ids: ['1', '2'] }) + expect(parseFilters({ ids: ['1', '2'] }, { allowedFields: ['ids'] })).toEqual({ ids: ['1', '2'] }) }) it('parses bracket op [gte] without coercion (no schema)', () => { - const out = parseFilters({ age: { gte: '18' } }) + const out = parseFilters({ age: { gte: '18' } }, { allowedFields: ['age'] }) expect(out).toEqual({ age: { gte: '18' } }) }) it('parses [in] with csv split', () => { - const out = parseFilters({ status: { in: 'active,pending' } }) + const out = parseFilters({ status: { in: 'active,pending' } }, { allowedFields: ['status'] }) expect(out).toEqual({ status: { in: ['active', 'pending'] } }) }) it('parses [in] empty string → []', () => { - const out = parseFilters({ status: { in: '' } }) + const out = parseFilters({ status: { in: '' } }, { allowedFields: ['status'] }) expect(out).toEqual({ status: { in: [] } }) }) it('parses [isNull] truthy → true', () => { - expect(parseFilters({ deletedAt: { isNull: '1' } })).toEqual({ deletedAt: { isNull: true } }) - expect(parseFilters({ deletedAt: { isNull: 'true' } })).toEqual({ deletedAt: { isNull: true } }) + expect(parseFilters({ deletedAt: { isNull: '1' } }, { allowedFields: ['deletedAt'] })).toEqual({ deletedAt: { isNull: true } }) + expect(parseFilters({ deletedAt: { isNull: 'true' } }, { allowedFields: ['deletedAt'] })).toEqual({ deletedAt: { isNull: true } }) }) it('drops [isNull] falsy', () => { - expect(parseFilters({ deletedAt: { isNull: '0' } })).toEqual({}) - expect(parseFilters({ deletedAt: { isNull: 'false' } })).toEqual({}) + expect(parseFilters({ deletedAt: { isNull: '0' } }, { allowedFields: ['deletedAt'] })).toEqual({}) + expect(parseFilters({ deletedAt: { isNull: 'false' } }, { allowedFields: ['deletedAt'] })).toEqual({}) }) it('preserves multi-op same field', () => { - const out = parseFilters({ age: { gte: '18', lt: '65' } }) + const out = parseFilters({ age: { gte: '18', lt: '65' } }, { allowedFields: ['age'] }) expect(out).toEqual({ age: { gte: '18', lt: '65' } }) }) it('throws on unknown op', () => { - expect(() => parseFilters({ age: { foo: '1' } })).toThrow(ValidationError) + expect(() => parseFilters({ age: { foo: '1' } }, { allowedFields: ['age'] })).toThrow(ValidationError) }) it('throws when field not in allowedFields', () => { expect(() => parseFilters({ status: 'x' }, { allowedFields: ['email'] })).toThrow(ValidationError) }) + it('throws when field not in schema-derived allowlist', () => { + const schema: FieldSchema = { email: { type: 'string' } } + expect(() => parseFilters({ status: 'x' }, { schema })).toThrow(ValidationError) + }) + it('skips reserved keys', () => { - expect(parseFilters({ offset: '0', limit: '25', sort: '-id', cursor: 'abc', email: 'x' })).toEqual({ email: 'x' }) + expect(parseFilters({ offset: '0', limit: '25', sort: '-id', cursor: 'abc', email: 'x' }, { allowedFields: ['email'] })).toEqual({ email: 'x' }) }) it('coerces via schema FieldRule.type', () => { @@ -88,18 +101,26 @@ describe('parseFilters', () => { expect(out.createdAt).toEqual({ gt: new Date('2024-01-01') }) }) + it('schema keys act as allowlist when allowedFields absent', () => { + const schema: FieldSchema = { age: { type: 'integer' }, email: { type: 'string' } } + const out = parseFilters({ age: { gte: '18' }, email: 'x' }, { schema }) + expect(out.age).toEqual({ gte: 18 }) + expect(out.email).toBe('x') + }) + it('throws on bad integer coercion', () => { const schema: FieldSchema = { age: { type: 'integer' } } expect(() => parseFilters({ age: 'abc' }, { schema })).toThrow(ValidationError) }) it('coerceFields option overrides schema', () => { - const out = parseFilters({ age: { gte: '18' } }, { coerceFields: { age: 'integer' } }) + const schema: FieldSchema = { age: { type: 'number' } } + const out = parseFilters({ age: { gte: '18' } }, { schema, coerceFields: { age: 'integer' } }) expect(out).toEqual({ age: { gte: 18 } }) }) it('drops empty bracket object', () => { - const out = parseFilters({ foo: {} }) + const out = parseFilters({ foo: {} }, { allowedFields: ['foo'] }) expect(out).toEqual({}) }) @@ -108,4 +129,50 @@ describe('parseFilters', () => { const out = parseFilters({ age: { in: '18,21,30' } }, { schema }) expect(out).toEqual({ age: { in: [18, 21, 30] } }) }) + + describe('error message bounding', () => { + it('truncates very long raw values in error messages', () => { + const schema: FieldSchema = { age: { type: 'integer' } } + const longVal = 'a'.repeat(200) + let msg = '' + try { + parseFilters({ age: longVal }, { schema }) + } catch (e) { + msg = (e as Error).message + } + expect(msg.length).toBeLessThanOrEqual(200) + }) + + it('strips control characters from error messages', () => { + const schema: FieldSchema = { age: { type: 'integer' } } + let msg = '' + try { + parseFilters({ age: 'bad\x00val\x1f' }, { schema }) + } catch (e) { + msg = (e as Error).message + } + expect(msg).not.toMatch(/[\x00-\x1f]/) + }) + }) + + describe('prototype pollution guard', () => { + it('drops __proto__ key and does not pollute Object prototype', () => { + const poisoned = JSON.parse('{"__proto__":{"isAdmin":true}}') as Record + const out = parseFilters(poisoned, { allowedFields: [] }) + expect(Object.keys(out)).not.toContain('__proto__') + expect(({} as Record).isAdmin).toBeUndefined() + }) + + it('drops constructor key', () => { + const poisoned = JSON.parse('{"constructor":{"prototype":{"isAdmin":true}}}') as Record + const out = parseFilters(poisoned, { allowedFields: [] }) + expect(Object.keys(out)).not.toContain('constructor') + }) + + it('drops prototype key', () => { + const poisoned = JSON.parse('{"prototype":{"isAdmin":true}}') as Record + const out = parseFilters(poisoned, { allowedFields: [] }) + expect(Object.keys(out)).not.toContain('prototype') + }) + }) }) diff --git a/node/test/utils/parseSort.test.ts b/node/test/utils/parseSort.test.ts index cf5e5b9c..2d6f0d8c 100644 --- a/node/test/utils/parseSort.test.ts +++ b/node/test/utils/parseSort.test.ts @@ -10,42 +10,50 @@ interface Row { describe('parseSort', () => { + it('throws when allowedFields not provided', () => { + expect(() => parseSort({ sort: 'name' })).toThrow('parseSort: pass allowedFields; [] to allow none') + }) + + it('throws when allowedFields not provided even with no sort in query', () => { + expect(() => parseSort({})).toThrow('parseSort: pass allowedFields; [] to allow none') + }) + it('returns undefined when sort absent', () => { - expect(parseSort({})).toBeUndefined() + expect(parseSort({}, { allowedFields: [] })).toBeUndefined() }) it('returns undefined when sort empty string', () => { - expect(parseSort({ sort: '' })).toBeUndefined() + expect(parseSort({ sort: '' }, { allowedFields: [] })).toBeUndefined() }) it('parses single asc', () => { - expect(parseSort({ sort: 'name' })).toEqual([{ field: 'name', direction: 'asc' }]) + expect(parseSort({ sort: 'name' }, { allowedFields: ['name'] })).toEqual([{ field: 'name', direction: 'asc' }]) }) it('parses leading - as desc', () => { - expect(parseSort({ sort: '-createdAt' })).toEqual([{ field: 'createdAt', direction: 'desc' }]) + expect(parseSort({ sort: '-createdAt' }, { allowedFields: ['createdAt'] })).toEqual([{ field: 'createdAt', direction: 'desc' }]) }) it('parses leading + as asc', () => { - expect(parseSort({ sort: '+name' })).toEqual([{ field: 'name', direction: 'asc' }]) + expect(parseSort({ sort: '+name' }, { allowedFields: ['name'] })).toEqual([{ field: 'name', direction: 'asc' }]) }) it('parses csv list with mixed directions', () => { - expect(parseSort({ sort: '-createdAt,name' })).toEqual([ + expect(parseSort({ sort: '-createdAt,name' }, { allowedFields: ['createdAt', 'name'] })).toEqual([ { field: 'createdAt', direction: 'desc' }, { field: 'name', direction: 'asc' }, ]) }) it('tolerates whitespace', () => { - expect(parseSort({ sort: ' -createdAt , name ' })).toEqual([ + expect(parseSort({ sort: ' -createdAt , name ' }, { allowedFields: ['createdAt', 'name'] })).toEqual([ { field: 'createdAt', direction: 'desc' }, { field: 'name', direction: 'asc' }, ]) }) it('skips empty tokens', () => { - expect(parseSort({ sort: 'a,,b' })).toEqual([ + expect(parseSort({ sort: 'a,,b' }, { allowedFields: ['a', 'b'] })).toEqual([ { field: 'a', direction: 'asc' }, { field: 'b', direction: 'asc' }, ]) @@ -55,12 +63,39 @@ describe('parseSort', () => { expect(() => parseSort({ sort: 'foo' }, { allowedFields: ['name'] })).toThrow(ValidationError) }) + it('throws ValidationError for any field when allowedFields is empty', () => { + expect(() => parseSort({ sort: 'name' }, { allowedFields: [] })).toThrow(ValidationError) + }) + it('accepts allowed field', () => { expect(parseSort({ sort: '-createdAt' }, { allowedFields: ['createdAt'] })) .toEqual([{ field: 'createdAt', direction: 'desc' }]) }) it('throws on non-string sort', () => { - expect(() => parseSort({ sort: 123 })).toThrow(ValidationError) + expect(() => parseSort({ sort: 123 }, { allowedFields: [] })).toThrow(ValidationError) + }) + + describe('error message bounding', () => { + it('truncates very long field names in error messages', () => { + const longField = 'x'.repeat(200) + let msg = '' + try { + parseSort({ sort: longField }, { allowedFields: [] }) + } catch (e) { + msg = (e as Error).message + } + expect(msg.length).toBeLessThanOrEqual(200) + }) + + it('strips control characters from error messages', () => { + let msg = '' + try { + parseSort({ sort: 'bad\x00field\x1f' }, { allowedFields: [] }) + } catch (e) { + msg = (e as Error).message + } + expect(msg).not.toMatch(/[\x00-\x1f]/) + }) }) }) diff --git a/node/test/utils/sanitize.test.ts b/node/test/utils/sanitize.test.ts new file mode 100644 index 00000000..fabae0bb --- /dev/null +++ b/node/test/utils/sanitize.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' +import { createSanitizer } from '../../src/utils/sanitize' +import { traverseEntity } from '../../src/utils/sanitize/sanitize' +import { coerceNumber } from '../../src/utils/sanitize/visitors' + +describe('coerceNumber visitor', () => { + function applyCoerceNumber(value: unknown): unknown { + const result = traverseEntity(coerceNumber, { val: {} }, { val: value }) as Record + return result.val + } + + it('coerces plain integer string', () => { + expect(applyCoerceNumber('42')).toBe(42) + }) + + it('coerces decimal string', () => { + expect(applyCoerceNumber('3.14')).toBe(3.14) + }) + + it('coerces negative string', () => { + expect(applyCoerceNumber('-7')).toBe(-7) + }) + + it('rejects hex string (0x3e8)', () => { + expect(applyCoerceNumber('0x3e8')).toBe('0x3e8') + }) + + it('rejects scientific notation (1e2)', () => { + expect(applyCoerceNumber('1e2')).toBe('1e2') + }) + + it('rejects scientific notation with explicit exponent (2.5E10)', () => { + expect(applyCoerceNumber('2.5E10')).toBe('2.5E10') + }) + + it('leaves non-string values unchanged', () => { + expect(applyCoerceNumber(99)).toBe(99) + expect(applyCoerceNumber(null)).toBe(null) + }) + + it('leaves empty string unchanged', () => { + expect(applyCoerceNumber('')).toBe('') + }) +}) + +interface Item { + name: string + value: number +} + +const schema = { + name: { type: 'string' as const }, + value: { type: 'number' as const }, +} + +describe('prototype pollution guard', () => { + describe('createSanitizer — fastPath (no custom visitors)', () => { + const sanitizer = createSanitizer(schema) + + it('input: drops __proto__ and does not pollute Object prototype', () => { + const poisoned = JSON.parse('{"__proto__":{"isAdmin":true},"name":"x","value":1}') as Record + const out = sanitizer.input(poisoned) as Record + expect(Object.keys(out)).not.toContain('__proto__') + expect(({} as Record).isAdmin).toBeUndefined() + }) + + it('input: drops constructor key', () => { + const poisoned = JSON.parse('{"constructor":{"prototype":{"isAdmin":true}},"name":"x","value":1}') as Record + const out = sanitizer.input(poisoned) as Record + expect(Object.keys(out)).not.toContain('constructor') + }) + + it('input: drops prototype key', () => { + const poisoned = JSON.parse('{"prototype":{"isAdmin":true},"name":"x","value":1}') as Record + const out = sanitizer.input(poisoned) as Record + expect(Object.keys(out)).not.toContain('prototype') + }) + + it('output: drops __proto__ and does not pollute Object prototype', () => { + const poisoned = JSON.parse('{"__proto__":{"isAdmin":true},"name":"x","value":1}') as Record + const out = sanitizer.output(poisoned) as Record + expect(Object.keys(out)).not.toContain('__proto__') + expect(({} as Record).isAdmin).toBeUndefined() + }) + + it('output: drops constructor key', () => { + const poisoned = JSON.parse('{"constructor":{"prototype":{"isAdmin":true}},"name":"x","value":1}') as Record + const out = sanitizer.output(poisoned) as Record + expect(Object.keys(out)).not.toContain('constructor') + }) + + it('query: drops __proto__ and does not pollute Object prototype', () => { + const poisoned = JSON.parse('{"__proto__":{"isAdmin":true},"name":"x"}') as Record + const out = sanitizer.query(poisoned) as Record + expect(Object.keys(out)).not.toContain('__proto__') + expect(({} as Record).isAdmin).toBeUndefined() + }) + + it('query: drops constructor key', () => { + const poisoned = JSON.parse('{"constructor":{"prototype":{"isAdmin":true}},"name":"x"}') as Record + const out = sanitizer.query(poisoned) as Record + expect(Object.keys(out)).not.toContain('constructor') + }) + }) + + describe('traverseEntity', () => { + it('drops __proto__ and does not pollute Object prototype', () => { + const poisoned = JSON.parse('{"__proto__":{"isAdmin":true},"name":"x","value":1}') as Record + const out = traverseEntity((_ctx, _actions) => {}, schema, poisoned) as Record + expect(Object.keys(out)).not.toContain('__proto__') + expect(({} as Record).isAdmin).toBeUndefined() + }) + + it('drops constructor key', () => { + const poisoned = JSON.parse('{"constructor":{"prototype":{"isAdmin":true}},"name":"x","value":1}') as Record + const out = traverseEntity((_ctx, _actions) => {}, schema, poisoned) as Record + expect(Object.keys(out)).not.toContain('constructor') + }) + + it('drops prototype key', () => { + const poisoned = JSON.parse('{"prototype":{"isAdmin":true},"name":"x","value":1}') as Record + const out = traverseEntity((_ctx, _actions) => {}, schema, poisoned) as Record + expect(Object.keys(out)).not.toContain('prototype') + }) + }) +}) diff --git a/package-lock.json b/package-lock.json index b09ab232..848fa3a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,18 @@ "base", "logging", "serializer", - "react-components", - "game-components", + "web-components", "phaser-plus", "node", + "taskforge", "examples" ], + "dependencies": { + "next": "16.2.9" + }, "devDependencies": { "@eslint/js": "^10.0.1", - "concurrently": "9.2.1", + "concurrently": "^9.2.3", "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", "phaser": "^4.1.0", @@ -35,24 +38,38 @@ }, "base": { "name": "@toolcase/base", - "version": "3.0.3", + "version": "5.0.0", "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0" + }, "engines": { "node": ">=18" } }, + "base/node_modules/@types/node": { + "version": "20.19.43", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "base/node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, "examples": { "name": "@toolcase/examples", "license": "MIT", "dependencies": { "@toolcase/base": "*", - "@toolcase/game-components": "*", "@toolcase/logging": "*", "@toolcase/node": "*", "@toolcase/phaser-plus": "*", - "@toolcase/react-components": "*", "@toolcase/serializer": "*", - "bootstrap": "^5.3.3", + "@toolcase/web-components": "*", "bootstrap-icons": "^1.11.3", "phaser": "^4.0.0", "react": "^19.1.0", @@ -67,20 +84,9 @@ "vite": "^6.3.1" } }, - "game-components": { - "name": "@toolcase/game-components", - "version": "3.0.2", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@toolcase/base": "3.x.x" - } - }, "logging": { "name": "@toolcase/logging", - "version": "3.0.2", + "version": "5.0.0", "license": "MIT", "engines": { "node": ">=18" @@ -88,7 +94,7 @@ }, "node": { "name": "@toolcase/node", - "version": "4.0.0", + "version": "5.0.0", "license": "MIT", "devDependencies": { "@fastify/cors": "^11.2.0", @@ -105,8 +111,8 @@ }, "peerDependencies": { "@fastify/cors": "^11.0.0", - "@toolcase/base": "^3.0.0", - "@toolcase/serializer": "^3.0.0", + "@toolcase/base": "^5.0.0", + "@toolcase/serializer": "^5.0.0", "fastify": "^5.0.0", "jose": "^5.10.0", "redis": "^5.0.0", @@ -134,13 +140,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -149,9 +155,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -159,21 +165,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -200,14 +206,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -217,14 +223,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -244,9 +250,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -254,29 +260,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -286,9 +292,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -296,9 +302,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -306,9 +312,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -316,9 +322,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -326,27 +332,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -356,13 +362,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -372,13 +378,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -388,33 +394,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -422,24 +428,23 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { @@ -447,9 +452,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -464,9 +469,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -481,9 +486,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -498,9 +503,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -515,9 +520,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -532,9 +537,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -549,9 +554,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -566,9 +571,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -583,9 +588,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -600,9 +605,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -617,9 +622,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -634,9 +639,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -651,9 +656,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -668,9 +673,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -685,9 +690,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -702,9 +707,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -719,9 +724,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -736,9 +741,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -753,9 +758,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -770,9 +775,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -787,9 +792,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -804,9 +809,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -821,9 +826,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -838,9 +843,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -855,9 +860,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -872,9 +877,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -946,9 +951,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1003,9 +1008,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1244,6 +1249,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -1358,6 +1373,38 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", @@ -1472,6 +1519,50 @@ "@img/sharp-libvips-linux-arm64": "1.0.4" } }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, "node_modules/@img/sharp-linux-s390x": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", @@ -1584,6 +1675,25 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-ia32": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", @@ -1674,6 +1784,140 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@next/env": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", + "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", + "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", + "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", + "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", + "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", + "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", + "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", + "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", + "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1686,18 +1930,20 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" } }, "node_modules/@publint/pack": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.4.tgz", - "integrity": "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.5.tgz", + "integrity": "sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==", "dev": true, "license": "MIT", + "dependencies": { + "tinyexec": "^1.2.4" + }, "engines": { "node": ">=18" }, @@ -1790,9 +2036,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", - "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "cpu": [ "arm" ], @@ -1804,9 +2050,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", - "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -1818,9 +2064,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", - "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -1832,9 +2078,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", - "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -1846,9 +2092,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", - "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "cpu": [ "arm64" ], @@ -1860,9 +2106,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", - "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -1874,9 +2120,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", - "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], @@ -1888,9 +2134,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", - "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "cpu": [ "arm" ], @@ -1902,9 +2148,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", - "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], @@ -1916,9 +2162,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", - "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], @@ -1930,9 +2176,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", - "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", "cpu": [ "loong64" ], @@ -1944,9 +2190,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", - "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", "cpu": [ "loong64" ], @@ -1958,9 +2204,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", - "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", "cpu": [ "ppc64" ], @@ -1972,9 +2218,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", - "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "cpu": [ "ppc64" ], @@ -1986,9 +2232,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", - "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ "riscv64" ], @@ -2000,9 +2246,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", - "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ "riscv64" ], @@ -2014,9 +2260,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", - "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], @@ -2028,9 +2274,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", - "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], @@ -2042,9 +2288,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", - "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], @@ -2056,9 +2302,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", - "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", "cpu": [ "x64" ], @@ -2070,9 +2316,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", - "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -2084,9 +2330,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", - "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -2098,9 +2344,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", - "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "cpu": [ "ia32" ], @@ -2112,9 +2358,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", - "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -2126,9 +2372,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", - "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "cpu": [ "x64" ], @@ -2147,10 +2393,13 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.2.14.tgz", - "integrity": "sha512-wpCQMhf5p5GhNg2MmGKXzUNwxe7zRiCsmqYsamez2beP7mKPCSiu+BjZcdN95yYSzO857kr0VfQewmGpS77nqA==", - "license": "MIT" + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } }, "node_modules/@toolcase/base": { "resolved": "base", @@ -2160,10 +2409,6 @@ "resolved": "examples", "link": true }, - "node_modules/@toolcase/game-components": { - "resolved": "game-components", - "link": true - }, "node_modules/@toolcase/logging": { "resolved": "logging", "link": true @@ -2176,14 +2421,18 @@ "resolved": "phaser-plus", "link": true }, - "node_modules/@toolcase/react-components": { - "resolved": "react-components", - "link": true - }, "node_modules/@toolcase/serializer": { "resolved": "serializer", "link": true }, + "node_modules/@toolcase/taskforge": { + "resolved": "taskforge", + "link": true + }, + "node_modules/@toolcase/web-components": { + "resolved": "web-components", + "link": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2255,9 +2504,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -2268,19 +2517,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" - } - }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -2298,17 +2538,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2321,7 +2561,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/parser": "^8.62.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2337,16 +2577,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3" }, "engines": { @@ -2362,14 +2602,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "engines": { @@ -2384,14 +2624,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2402,9 +2642,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", "dev": true, "license": "MIT", "engines": { @@ -2419,15 +2659,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2444,9 +2684,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", "dev": true, "license": "MIT", "engines": { @@ -2458,16 +2698,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2486,16 +2726,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2510,13 +2750,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2549,16 +2789,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2567,13 +2807,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2594,9 +2834,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -2607,13 +2847,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -2621,14 +2861,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2637,9 +2877,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -2647,13 +2887,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2669,9 +2909,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -2835,10 +3075,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", - "dev": true, + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -2847,25 +3086,6 @@ "node": ">=6.0.0" } }, - "node_modules/bootstrap": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", - "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "license": "MIT", - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, "node_modules/bootstrap-icons": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", @@ -2883,9 +3103,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -2896,9 +3116,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -2916,10 +3136,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -2956,10 +3176,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", - "dev": true, + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "funding": [ { "type": "opencollective", @@ -3032,6 +3251,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3113,15 +3338,15 @@ } }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.8.4", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -3235,26 +3460,16 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, - "node_modules/dropzone": { - "version": "6.0.0-beta.2", - "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-6.0.0-beta.2.tgz", - "integrity": "sha512-k44yLuFFhRk53M8zP71FaaNzJYIzr99SKmpbO/oZKNslDjNXQsBTdfLs+iONd0U0L94zzlFzRnFdqbLcs7h9fQ==", - "license": "MIT", - "dependencies": { - "@swc/helpers": "^0.2.13", - "just-extend": "^5.0.0" - } - }, "node_modules/electron-to-chromium": { - "version": "1.5.349", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", - "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", "dev": true, "license": "ISC" }, @@ -3273,9 +3488,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3286,32 +3501,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -3338,18 +3553,21 @@ } }, "node_modules/eslint": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", - "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", + "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -3861,10 +4079,10 @@ } }, "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", - "dev": true, + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.7.tgz", + "integrity": "sha512-47Xb+LFbZ/ZIjQMj6Q5J3IfK7PJFuqRdFOC9FpGgRTK6U2dAEVmkR9hp58qU4FpYux5YXpneDwkj2EP6lppzFA==", + "devOptional": true, "license": "MIT" }, "node_modules/imurmurhash": { @@ -4028,12 +4246,6 @@ "node": ">=6" } }, - "node_modules/just-extend": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz", - "integrity": "sha512-b+z6yF1d4EOyDgylzQo5IminlUmzSeqR1hs/bzjBNjuGras4FXq/6TrzjxfN0j+TmI0ltJzTNlqXUMCniciwKQ==", - "license": "MIT" - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4159,6 +4371,12 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-static": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.21.0.tgz", + "integrity": "sha512-6248z2/4sEyKkYAPPUYxOPiB2RCfMmLdMHuoOhsTFnoD40ixAoHmTVhOPux8ADa1NTBmzpEKF7WNePm+Ms503Q==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4228,10 +4446,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -4253,162 +4470,626 @@ "dev": true, "license": "MIT" }, - "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, + "node_modules/next": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", + "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", "license": "MIT", + "dependencies": { + "@next/env": "16.2.9", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, "engines": { - "node": ">=0.10.0" + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.9", + "@next/swc-darwin-x64": "16.2.9", + "@next/swc-linux-arm64-gnu": "16.2.9", + "@next/swc-linux-arm64-musl": "16.2.9", + "@next/swc-linux-x64-gnu": "16.2.9", + "@next/swc-linux-x64-musl": "16.2.9", + "@next/swc-win32-arm64-msvc": "16.2.9", + "@next/swc-win32-x64-msvc": "16.2.9", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } } }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" + "node_modules/next/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" ], - "license": "MIT" - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "engines": { - "node": ">= 0.8.0" + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, + "node_modules/next/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, + "node_modules/next/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" } }, - "node_modules/package-manager-detector": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", - "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", - "dev": true, - "license": "MIT" + "node_modules/next/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node_modules/next/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node_modules/next/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" + "node_modules/next/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/phaser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.1.0.tgz", - "integrity": "sha512-ZXv5Bhyg2BqJGAAxNI2xvmzGXW9q+TwUG1RLri5ZDBYGGtcma6aWUO/eJ7EbozeqRd5fKdpo4ycNMQt+Bi5iYg==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.4" + "node_modules/next/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" + "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, + "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://opencollective.com/libvips" } }, - "node_modules/pino": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", - "dev": true, + "node_modules/next/node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/next/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/next/node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/next/node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/next/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/next/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/next/node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/phaser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.0.tgz", + "integrity": "sha512-9nSQZs4CJ9+V96i2mRC3BBStBcsQWGJJBRpdFYSbpL40/i/QM+wLofaCYMsxNyDk8WJOlHGZUh3uVRKa7lj6yg==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "dev": true, "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", @@ -4423,151 +5104,528 @@ "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, - "bin": { - "pino": "bin.js" + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "8.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.5.tgz", + "integrity": "sha512-zeE5LPpencAGXvsxyOYmEgJhxzHY8IsmPAFzstZVhDSVT8QH03q6gMZwZRaQGApevZbAL6u28ugs4CC+YKB2jQ==", + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" } }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "node_modules/publint": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.21.tgz", + "integrity": "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==", "dev": true, "license": "MIT", "dependencies": { - "split2": "^4.0.0" + "@publint/pack": "^0.1.4", + "package-manager-detector": "^1.6.0", + "picocolors": "^1.1.1", + "sade": "^1.8.1" + }, + "bin": { + "publint": "src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" } }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=0.10.0" } }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" } }, - "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true } - ], + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "dev": true, "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 18.19.0" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "lilconfig": "^3.1.1" + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" }, "engines": { - "node": ">= 18" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true + "engines": { + "node": ">=6" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" }, - "yaml": { - "optional": true + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8.0" + "node": ">=10" } }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "dev": true, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "devOptional": true, "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, "bin": { - "prettier": "bin/prettier.cjs" + "sass": "sass.js" }, "engines": { - "node": ">=14" + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/process-warning": { + "node_modules/sass/node_modules/readdirp": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", "dev": true, "funding": [ { @@ -4579,986 +5637,1031 @@ "url": "https://opencollective.com/fastify" } ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, - "node_modules/protobufjs": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.3.tgz", - "integrity": "sha512-LBYnMWkKLB8fE/ljROPDbCl7mgLSlI+oBe1fAAr5MTqFg4TIi0tYrVVurJvQggOjnUYMQtEZBjrej59ojMNTHQ==", + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, "hasInstallScript": true, - "license": "BSD-3-Clause", + "license": "Apache-2.0", "dependencies": { - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" }, "engines": { - "node": ">=12.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" } }, - "node_modules/publint": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.18.tgz", - "integrity": "sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog==", + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { - "@publint/pack": "^0.1.4", - "package-manager-detector": "^1.6.0", - "picocolors": "^1.1.1", - "sade": "^1.8.1" - }, - "bin": { - "publint": "src/cli.js" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://bjornlu.com/sponsor" + "node": ">=8" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dev": true, "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, - "peerDependencies": { - "react": "^19.2.5" + "engines": { + "node": ">=8" } }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/react-router": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", - "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "client-only": "0.0.1" }, "engines": { - "node": ">=20.0.0" + "node": ">= 12.0.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "peerDependenciesMeta": { - "react-dom": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { "optional": true } } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 14.18.0" + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "dev": true, - "license": "MIT", "engines": { - "node": ">= 12.13.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/redis": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", - "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { - "@redis/bloom": "5.12.1", - "@redis/client": "5.12.1", - "@redis/json": "5.12.1", - "@redis/search": "5.12.1", - "@redis/time-series": "5.12.1" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 18.19.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "any-promise": "^1.0.0" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "dev": true, "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, "engines": { - "node": ">=8" + "node": ">=0.8" } }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", "dev": true, "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, "engines": { - "node": ">=10" + "node": ">=20" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } + "license": "MIT" }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, - "node_modules/rollup": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", - "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.2", - "@rollup/rollup-android-arm64": "4.60.2", - "@rollup/rollup-darwin-arm64": "4.60.2", - "@rollup/rollup-darwin-x64": "4.60.2", - "@rollup/rollup-freebsd-arm64": "4.60.2", - "@rollup/rollup-freebsd-x64": "4.60.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", - "@rollup/rollup-linux-arm-musleabihf": "4.60.2", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", - "@rollup/rollup-linux-arm64-musl": "4.60.2", - "@rollup/rollup-linux-loong64-gnu": "4.60.2", - "@rollup/rollup-linux-loong64-musl": "4.60.2", - "@rollup/rollup-linux-ppc64-gnu": "4.60.2", - "@rollup/rollup-linux-ppc64-musl": "4.60.2", - "@rollup/rollup-linux-riscv64-gnu": "4.60.2", - "@rollup/rollup-linux-riscv64-musl": "4.60.2", - "@rollup/rollup-linux-s390x-gnu": "4.60.2", - "@rollup/rollup-linux-x64-gnu": "4.60.2", - "@rollup/rollup-linux-x64-musl": "4.60.2", - "@rollup/rollup-openbsd-x64": "4.60.2", - "@rollup/rollup-openharmony-arm64": "4.60.2", - "@rollup/rollup-win32-arm64-msvc": "4.60.2", - "@rollup/rollup-win32-ia32-msvc": "4.60.2", - "@rollup/rollup-win32-x64-gnu": "4.60.2", - "@rollup/rollup-win32-x64-msvc": "4.60.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" + "node": ">=18" } }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "mri": "^1.1.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=6" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=20" } }, - "node_modules/sass": { - "version": "1.99.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", - "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true, "license": "MIT", - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.1.5", - "source-map-js": ">=0.6.2 <2.0.0" - }, "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" + "tree-kill": "cli.js" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "engines": { + "node": ">=18.12" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" }, - "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=18" }, - "funding": { - "url": "https://opencollective.com/libvips" + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/tsup/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "prelude-ls": "^1.2.1" }, "engines": { - "node": ">=8" + "node": ">= 0.8.0" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=8" + "node": ">=14.17" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "node_modules/typescript-eslint": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" + }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "is-arrayish": "^0.3.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" + "punycode": "^2.1.0" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "ISC", + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">= 10.x" + "node": ">=18" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=18" } }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=18" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=0.8" + "node": ">=18" } }, - "node_modules/thread-stream": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", - "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=20" + "node": ">=18" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=18" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, - "node_modules/toad-cache": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", - "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "bin": { - "tree-kill": "cli.js" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" + "node": ">=18" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.8.0" + "node": ">=18" } }, - "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.17" + "node": ">=18" } }, - "node_modules/typescript-eslint": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", - "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.1", - "@typescript-eslint/parser": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" ], + "dev": true, "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "aix" + "openharmony" ], "engines": { "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/android-arm": { + "node_modules/vite/node_modules/@esbuild/sunos-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ - "arm" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "sunos" ], "engines": { "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { + "node_modules/vite/node_modules/@esbuild/win32-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -5566,753 +6669,842 @@ "license": "MIT", "optional": true, "os": [ - "android" + "win32" ], "engines": { "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/android-x64": { + "node_modules/vite/node_modules/@esbuild/win32-ia32": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ - "x64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "win32" ], "engines": { "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "node_modules/vite/node_modules/@esbuild/win32-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, "engines": { - "node": ">=18" + "node": ">= 8" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "license": "ISC" }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], + "node/node_modules/@types/node": { + "version": "20.19.39", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "undici-types": "~6.21.0" } }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], + "node/node_modules/undici-types": { + "version": "6.21.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "phaser-plus": { + "name": "@toolcase/phaser-plus", + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@tweakpane/plugin-essentials": "^0.2.1", + "tweakpane": "^4.0.5" + }, + "devDependencies": { + "@toolcase/base": "*", + "@toolcase/logging": "*", + "phaser": "^4.0.0" + }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@toolcase/base": "5.x", + "@toolcase/logging": "5.x", + "phaser": "4.x", + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, + "phaser-plus/node_modules/@tweakpane/plugin-essentials": { + "version": "0.2.1", "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "peerDependencies": { + "tweakpane": "^4.0.0" } }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, + "phaser-plus/node_modules/tweakpane": { + "version": "4.0.5", "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://github.com/sponsors/cocopon" } }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, + "serializer": { + "name": "@toolcase/serializer", + "version": "5.0.0", "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "protobufjs": "^8.0.1" + }, "engines": { "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, + "taskforge": { + "name": "@toolcase/taskforge", + "version": "5.0.0", "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@toolcase/base": "file:../base", + "@toolcase/web-components": "file:../web-components", + "next": "^16.2.9", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@types/node": "^22.5", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.5", + "typescript": "^5.5.0" + }, "engines": { - "node": ">=18" + "node": ">=22.5" } }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], + "taskforge/node_modules/@eslint/js": { + "version": "8.57.1", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], + "taskforge/node_modules/@types/node": { + "version": "22.20.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" + "dependencies": { + "undici-types": "~6.21.0" } }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], + "taskforge/node_modules/@types/react": { + "version": "18.3.31", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" } }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], + "taskforge/node_modules/@types/react-dom": { + "version": "18.3.7", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "peerDependencies": { + "@types/react": "^18.0.0" } }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], + "taskforge/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "taskforge/node_modules/brace-expansion": { + "version": "1.1.15", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "taskforge/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "taskforge/node_modules/eslint": { + "version": "8.57.1", "dev": true, - "hasInstallScript": true, "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, "bin": { - "esbuild": "bin/esbuild" + "eslint": "bin/eslint.js" }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "taskforge/node_modules/eslint-config-next": { + "version": "14.2.35", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "@next/eslint-plugin-next": "14.2.35", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" }, - "bin": { - "vitest": "vitest.mjs" + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "taskforge/node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/eslint-import-resolver-typescript" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" }, "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { + "eslint-plugin-import": { "optional": true }, - "jsdom": { + "eslint-plugin-import-x": { "optional": true - }, - "vite": { - "optional": false } } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "taskforge/node_modules/eslint-plugin-import": { + "version": "2.32.0", "dev": true, "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, "engines": { - "node": ">=18" + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "taskforge/node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" + "ms": "^2.1.1" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "taskforge/node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" + "esutils": "^2.0.2" }, "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "taskforge/node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", "dev": true, "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "taskforge/node_modules/eslint-plugin-react": { + "version": "7.37.5", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">=4" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "taskforge/node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0-canary-7118f5dd7-20230705", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "taskforge/node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", "dev": true, - "license": "ISC" + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "taskforge/node_modules/eslint-scope": { + "version": "7.2.2", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">=12" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "taskforge/node_modules/eslint-visitor-keys": { + "version": "3.4.3", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "taskforge/node_modules/espree": { + "version": "9.6.1", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, "engines": { - "node": ">=10" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node/node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "taskforge/node_modules/file-entry-cache": { + "version": "6.0.1", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" } }, - "node/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "taskforge/node_modules/flat-cache": { + "version": "3.2.0", "dev": true, - "license": "MIT" - }, - "phaser-plus": { - "name": "@toolcase/phaser-plus", - "version": "3.0.2", "license": "MIT", "dependencies": { - "@tweakpane/plugin-essentials": "^0.2.1", - "tweakpane": "^4.0.5" - }, - "devDependencies": { - "@toolcase/base": "*", - "@toolcase/logging": "*", - "phaser": "^4.0.0" + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@toolcase/base": "3.x", - "@toolcase/logging": "3.x", - "phaser": "4.x", - "react": ">=18", - "react-dom": ">=18" + "node": "^10.12.0 || >=12.0.0" + } + }, + "taskforge/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } + "engines": { + "node": "*" } }, - "phaser-plus/node_modules/@tweakpane/plugin-essentials": { - "version": "0.2.1", + "taskforge/node_modules/react": { + "version": "18.3.1", "license": "MIT", - "peerDependencies": { - "tweakpane": "^4.0.0" + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" } }, - "phaser-plus/node_modules/tweakpane": { - "version": "4.0.5", + "taskforge/node_modules/react-dom": { + "version": "18.3.1", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/cocopon" + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" } }, - "react-components": { - "name": "@toolcase/react-components", - "version": "3.0.5", + "taskforge/node_modules/scheduler": { + "version": "0.23.2", "license": "MIT", "dependencies": { - "dropzone": "^6.0.0-beta.2" + "loose-envify": "^1.1.0" + } + }, + "taskforge/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "taskforge/node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@toolcase/base": "3.x.x", - "react": ">=18", - "react-dom": ">=18" + "node": ">=14.17" } }, - "serializer": { - "name": "@toolcase/serializer", - "version": "3.0.2", + "taskforge/node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "web-components": { + "name": "@toolcase/web-components", + "version": "5.0.0", "license": "MIT", "dependencies": { - "protobufjs": "^8.0.1" + "@popperjs/core": "^2.11.8", + "lucide-static": "^1.18.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "react": "^19.1.0" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@toolcase/base": "5.x.x", + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } } } diff --git a/package.json b/package.json index c4cb9c95..1ca33024 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,27 @@ { "name": "@toolcase/root", "private": true, - "description": "Toolcase monorepo — JavaScript utilities, logging, serializer, React + Web Component UI, and Phaser game runtime.", + "description": "Toolcase monorepo — JavaScript utilities, logging, serializer, framework-free Web Component UI, and Phaser game runtime.", "scripts": { "build": "npm run build --workspaces", "test": "vitest run", - "lint": "eslint . --ignore-pattern examples/", - "lint:exports": "publint base && publint logging && publint serializer && publint react-components && publint phaser-plus && publint node", + "lint": "eslint .", + "lint:exports": "publint base && publint logging && publint serializer && publint web-components && publint phaser-plus && publint node", "format": "prettier --write ." }, "workspaces": [ "base", "logging", "serializer", - "react-components", - "game-components", + "web-components", "phaser-plus", "node", + "taskforge", "examples" ], "devDependencies": { "@eslint/js": "^10.0.1", - "concurrently": "9.2.1", + "concurrently": "^9.2.3", "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", "phaser": "^4.1.0", @@ -47,5 +47,14 @@ }, "engines": { "node": ">=18" + }, + "dependencies": { + "next": "16.2.9" + }, + "overrides": { + "postcss": "^8.5.15", + "tsup": { + "esbuild": "^0.28.1" + } } } diff --git a/phaser-plus/package.json b/phaser-plus/package.json index 3902c172..c8c65e4f 100644 --- a/phaser-plus/package.json +++ b/phaser-plus/package.json @@ -1,6 +1,6 @@ { "name": "@toolcase/phaser-plus", - "version": "3.0.2", + "version": "5.0.0", "description": "Opinionated runtime for Phaser 4 — scene lifecycle, feature modules, isometric camera, and a Tweakpane debugger in one package.", "type": "module", "source": "src/index.ts", @@ -48,8 +48,8 @@ }, "peerDependencies": { "phaser": "4.x", - "@toolcase/base": "3.x", - "@toolcase/logging": "3.x", + "@toolcase/base": "5.x", + "@toolcase/logging": "5.x", "react": ">=18", "react-dom": ">=18" }, diff --git a/phaser-plus/src/ai/PathFinder.ts b/phaser-plus/src/ai/PathFinder.ts index afd5acfa..b4566e43 100644 --- a/phaser-plus/src/ai/PathFinder.ts +++ b/phaser-plus/src/ai/PathFinder.ts @@ -45,7 +45,9 @@ const buildEdgeCost = (mesh: NavMesh) => (from: GridNode, to: GridNode): number const dx = Math.abs(to.x - from.x) const dy = Math.abs(to.y - from.y) const stepCost = (dx !== 0 && dy !== 0) ? DIAG : STRAIGHT - return stepCost * mesh.cost(to.x, to.y) + // Clamp to ≥ 1 so the octile heuristic (which assumes min step cost = 1) stays admissible. + // A mesh.cost() below 1 would let the heuristic over-estimate and produce non-optimal paths. + return stepCost * Math.max(1, mesh.cost(to.x, to.y)) } /** @@ -53,6 +55,22 @@ const buildEdgeCost = (mesh: NavMesh) => (from: GridNode, to: GridNode): number * `budgetMs` per `onUpdate` tick stepping each one. Each query owns an * `@toolcase/base` `AStar` instance whose `step()` is invoked round-robin until * the path resolves or the budget runs out. + * + * ## `PATH_FAILED` reason strings + * | Reason | Source | + * |---|---| + * | `'end_blocked'` | The destination tile is not navigable (`mesh.isBlocked`). | + * | `'error'` | The `cost()`/heuristic threw during a search step. | + * | `'exhausted'` | The frontier was fully explored without reaching the goal (unreachable). | + * | `'max_iterations'` | The per-query `maxIterations` cap was hit before the goal was found. | + * + * **Note on `maxIterations`:** The default of 5 000 is intentionally conservative. + * On an 8-connected grid the frontier expands roughly as a circle, so a 100×100 + * grid can already exhaust the budget for long diagonal paths. Callers that need + * large maps should raise the cap via `findPath(…, maxIterations)`. Because both + * `'exhausted'` and `'max_iterations'` can mean "no path exists", they are + * indistinguishable from the caller's perspective — both should be treated as + * a permanent failure. */ export default class PathFinder extends Feature { @@ -122,7 +140,12 @@ export default class PathFinder extends Feature { while (this.active.length > 0 && performance.now() - start < this.budgetMs && safety < safetyLimit) { const path = this.active.shift()! const search = path.search! - search.step() + try { + search.step() + } catch { + path.markFailed('error') + continue + } safety++ if (search.isComplete) continue diff --git a/phaser-plus/src/ai/TilemapNavMesh.ts b/phaser-plus/src/ai/TilemapNavMesh.ts new file mode 100644 index 00000000..daad93ce --- /dev/null +++ b/phaser-plus/src/ai/TilemapNavMesh.ts @@ -0,0 +1,35 @@ +import type { Tilemaps } from 'phaser' +import NavMesh from './NavMesh' + +export default class TilemapNavMesh extends NavMesh { + + private readonly map: Tilemaps.Tilemap + private readonly layerRef: number | string | undefined + private readonly walkableSet: ReadonlySet + + constructor(map: Tilemaps.Tilemap, walkable: number[], layer?: number | string) { + super() + this.map = map + this.layerRef = layer + this.walkableSet = new Set(walkable) + } + + override isBlocked(x: number, y: number): boolean { + const tile = this.layerRef !== undefined + ? this.map.getTileAt(x, y, false, this.layerRef) + : this.map.getTileAt(x, y, false) + if (tile === null) return true + if (tile.index === -1) return true + return !this.walkableSet.has(tile.index) + } + + override cost(x: number, y: number): number { + const tile = this.layerRef !== undefined + ? this.map.getTileAt(x, y, false, this.layerRef) + : this.map.getTileAt(x, y, false) + const props = tile?.properties as Record | undefined + const c = props?.cost + return typeof c === 'number' && c > 0 ? c : 1 + } + +} diff --git a/phaser-plus/src/ai/index.ts b/phaser-plus/src/ai/index.ts index c2c97651..3577d165 100644 --- a/phaser-plus/src/ai/index.ts +++ b/phaser-plus/src/ai/index.ts @@ -1,11 +1,13 @@ import NavMesh from './NavMesh' import Path, { PATH_FOUND, PATH_FAILED, type Waypoint, type GridNode } from './Path' import PathFinder from './PathFinder' +import TilemapNavMesh from './TilemapNavMesh' export { NavMesh, Path, PathFinder, + TilemapNavMesh, PATH_FOUND, PATH_FAILED } diff --git a/phaser-plus/src/assets/AssetFeature.ts b/phaser-plus/src/assets/AssetFeature.ts new file mode 100644 index 00000000..3055d1dc --- /dev/null +++ b/phaser-plus/src/assets/AssetFeature.ts @@ -0,0 +1,292 @@ +import Feature from '../features/Feature' +import type Scene from '../engine/Scene' +import { retry } from '@toolcase/base' + +export const ASSET_PROGRESS = 'asset_feature.progress' +export const ASSET_LOAD_COMPLETE = 'asset_feature.load_complete' +export const ASSET_LOAD_ERROR = 'asset_feature.load_error' + +export interface AssetImage { + key: string + url: string +} + +export interface AssetAtlas { + key: string + textureUrl: string + atlasUrl: string +} + +export interface AssetAudio { + key: string + url: string | string[] +} + +export interface AssetBitmapFont { + key: string + textureUrl: string + fontDataUrl: string +} + +export interface AssetBundle { + images?: AssetImage[] + atlases?: AssetAtlas[] + audio?: AssetAudio[] + fonts?: AssetBitmapFont[] +} + +export interface AssetManifest { + bundles: Record +} + +type AssetType = 'image' | 'atlas' | 'audio' | 'font' + +interface Entry { + type: AssetType + key: string + url: string | string[] + atlasUrl?: string + fontDataUrl?: string +} + +class AssetFeature extends Feature { + + retries = 3 + + private _manifest: AssetManifest | null = null + + private _loaded: Set = new Set() + + private _entries: Map = new Map() + + private _loading = false + + constructor(scene: Scene, key: string) { + super(scene, key) + this.suppressWarning(ASSET_PROGRESS, ASSET_LOAD_COMPLETE, ASSET_LOAD_ERROR) + } + + override onCreate(): void {} + + override onDestroy(): void { + this._loaded.clear() + this._entries.clear() + this._manifest = null + } + + define(manifest: AssetManifest): this { + this._manifest = manifest + this._entries.clear() + for (const bundle of Object.values(manifest.bundles)) { + for (const img of bundle.images ?? []) { + this._entries.set(img.key, { type: 'image', key: img.key, url: img.url }) + } + for (const atlas of bundle.atlases ?? []) { + this._entries.set(atlas.key, { type: 'atlas', key: atlas.key, url: atlas.textureUrl, atlasUrl: atlas.atlasUrl }) + } + for (const audio of bundle.audio ?? []) { + this._entries.set(audio.key, { type: 'audio', key: audio.key, url: audio.url }) + } + for (const font of bundle.fonts ?? []) { + this._entries.set(font.key, { type: 'font', key: font.key, url: font.textureUrl, fontDataUrl: font.fontDataUrl }) + } + } + return this + } + + async load(...bundleNames: string[]): Promise { + if (this._manifest === null) { + throw new Error('AssetFeature: call define(manifest) before load()') + } + if (this._loading) { + throw new Error('AssetFeature: load() already in progress') + } + + const names = bundleNames.length > 0 ? bundleNames : Object.keys(this._manifest.bundles) + const entries = this.resolveEntries(names) + const pending = entries.filter(e => !this._loaded.has(e.key)) + + if (pending.length === 0) { + this.emit(ASSET_PROGRESS, 1) + this.emit(ASSET_LOAD_COMPLETE, names) + return + } + + this._loading = true + const total = pending.length + let completed = 0 + + try { + await retry( + () => { + const remaining = pending.filter(e => !this._loaded.has(e.key)) + return this.doLoadBatch(remaining, (key) => { + this._loaded.add(key) + completed++ + this.emit(ASSET_PROGRESS, completed / total) + }) + }, + { retries: this.retries, minTimeout: 500, factor: 2 } + ) + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + this.emit(ASSET_LOAD_ERROR, error) + throw error + } finally { + this._loading = false + } + + this.emit(ASSET_LOAD_COMPLETE, names) + } + + has(key: string): boolean { + return this._loaded.has(key) + } + + async reload(key: string): Promise { + const entry = this._entries.get(key) + if (entry === undefined) { + throw new Error(`AssetFeature: no manifest entry for key="${key}"`) + } + const tempKey = `__assetfeature_reload_${key}` + const tempEntry: Entry = { ...entry, key: tempKey } + await this.doLoadBatch([tempEntry], () => {}) + this.swapAsset(entry.type, key, tempKey) + this._loaded.add(key) + } + + private resolveEntries(bundleNames: string[]): Entry[] { + if (this._manifest === null) return [] + const out: Entry[] = [] + for (const name of bundleNames) { + const bundle = this._manifest.bundles[name] + if (bundle === undefined) { + this.logger.warning(`bundle="${name}" not found in manifest`) + continue + } + for (const img of bundle.images ?? []) { + out.push(this._entries.get(img.key) ?? { type: 'image', key: img.key, url: img.url }) + } + for (const atlas of bundle.atlases ?? []) { + out.push(this._entries.get(atlas.key) ?? { type: 'atlas', key: atlas.key, url: atlas.textureUrl, atlasUrl: atlas.atlasUrl }) + } + for (const audio of bundle.audio ?? []) { + out.push(this._entries.get(audio.key) ?? { type: 'audio', key: audio.key, url: audio.url }) + } + for (const font of bundle.fonts ?? []) { + out.push(this._entries.get(font.key) ?? { type: 'font', key: font.key, url: font.textureUrl, fontDataUrl: font.fontDataUrl }) + } + } + return out + } + + private queueInLoader(entry: Entry): void { + const loader = this.scene.load as any + switch (entry.type) { + case 'image': + loader.image(entry.key, entry.url) + break + case 'atlas': + loader.atlas(entry.key, entry.url, entry.atlasUrl) + break + case 'audio': + loader.audio(entry.key, entry.url) + break + case 'font': + loader.bitmapFont(entry.key, entry.url, entry.fontDataUrl) + break + } + } + + private doLoadBatch(entries: Entry[], onFile: (key: string) => void): Promise { + return new Promise((resolve, reject) => { + if (entries.length === 0) { + resolve() + return + } + + const expected = new Set(entries.map(e => e.key)) + const failed: string[] = [] + + for (const entry of entries) { + this.queueInLoader(entry) + } + + const finish = () => { + cleanup() + if (failed.length > 0) { + reject(new Error(`Failed to load: ${failed.join(', ')}`)) + } else { + resolve() + } + } + + const onFileComplete = (key: string) => { + if (!expected.has(key)) return + expected.delete(key) + onFile(key) + if (expected.size === 0) finish() + } + + const onError = (file: { key: string }) => { + if (!expected.has(file.key)) return + expected.delete(file.key) + failed.push(file.key) + if (expected.size === 0) finish() + } + + const onComplete = () => { + if (expected.size === 0) return + const remaining = [...expected] + cleanup() + reject(new Error(`Loader completed with unresolved files: ${remaining.join(', ')}`)) + } + + const cleanup = () => { + this.scene.load.off('filecomplete', onFileComplete) + this.scene.load.off('loaderror', onError) + this.scene.load.off('complete', onComplete) + } + + this.scene.load.on('filecomplete', onFileComplete) + this.scene.load.on('loaderror', onError) + this.scene.load.on('complete', onComplete) + + ;(this.scene.load as any).start() + }) + } + + private swapAsset(type: AssetType, key: string, tempKey: string): void { + if (type === 'image' || type === 'atlas') { + const textures = this.scene.textures as any + if (textures.exists?.(key)) textures.remove?.(key) + textures.renameTexture?.(tempKey, key) + } else if (type === 'audio') { + const cache = (this.scene as any).cache?.audio + if (cache) { + const data = cache.get?.(tempKey) + if (data !== undefined) { + cache.remove?.(key) + cache.add?.(key, data) + cache.remove?.(tempKey) + } + } + } else if (type === 'font') { + const textures = this.scene.textures as any + if (textures.exists?.(key)) textures.remove?.(key) + textures.renameTexture?.(tempKey, key) + const fontCache = (this.scene as any).cache?.bitmapFont + if (fontCache) { + const data = fontCache.get?.(tempKey) + if (data !== undefined) { + fontCache.remove?.(key) + fontCache.add?.(key, data) + fontCache.remove?.(tempKey) + } + } + } + } + +} + +export default AssetFeature diff --git a/phaser-plus/src/assets/index.ts b/phaser-plus/src/assets/index.ts new file mode 100644 index 00000000..8aa27345 --- /dev/null +++ b/phaser-plus/src/assets/index.ts @@ -0,0 +1,10 @@ +import type { AssetImage, AssetAtlas, AssetAudio, AssetBitmapFont, AssetBundle, AssetManifest } from './AssetFeature' +import AssetFeature, { ASSET_PROGRESS, ASSET_LOAD_COMPLETE, ASSET_LOAD_ERROR } from './AssetFeature' + +export type { AssetImage, AssetAtlas, AssetAudio, AssetBitmapFont, AssetBundle, AssetManifest } +export { + AssetFeature, + ASSET_PROGRESS, + ASSET_LOAD_COMPLETE, + ASSET_LOAD_ERROR +} diff --git a/phaser-plus/src/audio/AudioFeature.ts b/phaser-plus/src/audio/AudioFeature.ts new file mode 100644 index 00000000..ebc3ed72 --- /dev/null +++ b/phaser-plus/src/audio/AudioFeature.ts @@ -0,0 +1,411 @@ +import Feature from '../features/Feature' +import type Scene from '../engine/Scene' +import type Debugger from '../debugger/Debugger' +import type AudioPanel from '../debugger/panels/AudioPanel' + +export const AUDIO_MUSIC_START = 'audio_feature.music_start' +export const AUDIO_MUSIC_END = 'audio_feature.music_end' +export const AUDIO_BUS_CHANGE = 'audio_feature.bus_change' + +interface Bus { + name: string + volume: number + mute: boolean + pan: number +} + +interface CrossfadeState { + from: any | null + fromStartVol: number + to: any + durationMs: number + elapsed: number +} + +interface FadeOutState { + sound: any + startVolume: number + durationMs: number + elapsed: number +} + +interface SfxPool { + sounds: any[] + cursor: number + maxVoices: number +} + +interface DuckState { + originalVolume: number + initialDuckVolume: number + currentDuckVolume: number + holdMs: number + holdElapsed: number + restoreDurationMs: number + restoreElapsed: number +} + +function clamp01(v: number): number { + return v < 0 ? 0 : v > 1 ? 1 : v +} + +function clamp(v: number, min: number, max: number): number { + return v < min ? min : v > max ? max : v +} + +class AudioFeature extends Feature { + + static readonly DEFAULT_BUSES: readonly string[] = ['music', 'sfx', 'ui', 'ambience'] + + private master: Bus = { name: 'master', volume: 1, mute: false, pan: 0 } + + private buses: Map = new Map() + + private currentMusic: Map = new Map() + + private crossfades: Map = new Map() + + private fadeOuts: Map = new Map() + + private sfxPools: Map = new Map() + + private ducks: Map = new Map() + + private listenerX = 0 + + private listenerY = 0 + + constructor(scene: Scene, key: string) { + super(scene, key) + } + + override onCreate(): void { + this.suppressWarning(AUDIO_MUSIC_START, AUDIO_MUSIC_END, AUDIO_BUS_CHANGE) + for (const name of AudioFeature.DEFAULT_BUSES) { + this.buses.set(name, { name, volume: 1, mute: false, pan: 0 }) + this.currentMusic.set(name, null) + } + } + + override onUpdate(_time: number, delta: number): void { + this.tickCrossfades(delta) + this.tickFadeOuts(delta) + this.tickDucks(delta) + } + + override onDestroy(): void { + for (const sound of this.currentMusic.values()) { + if (sound !== null) { sound.stop(); sound.destroy() } + } + for (const entry of this.sfxPools.values()) { + for (const s of entry.sounds) { s.stop(); s.destroy() } + } + this.currentMusic.clear() + this.crossfades.clear() + this.fadeOuts.clear() + this.sfxPools.clear() + this.ducks.clear() + this.buses.clear() + } + + addBus(name: string): this { + if (!this.buses.has(name)) { + this.buses.set(name, { name, volume: 1, mute: false, pan: 0 }) + this.currentMusic.set(name, null) + } + return this + } + + removeBus(name: string): this { + const sound = this.currentMusic.get(name) ?? null + if (sound !== null) { sound.stop(); sound.destroy() } + this.currentMusic.delete(name) + this.crossfades.delete(name) + this.fadeOuts.delete(name) + this.ducks.delete(name) + this.buses.delete(name) + return this + } + + getBus(name: string): Readonly | null { + return this.buses.get(name) ?? null + } + + setMasterVolume(volume: number): this { + this.master.volume = clamp01(volume) + this.applyAll() + return this + } + + setMasterMute(mute: boolean): this { + this.master.mute = mute + this.applyAll() + return this + } + + getMasterVolume(): number { return this.master.volume } + + isMasterMute(): boolean { return this.master.mute } + + setVolume(busName: string, volume: number): this { + const bus = this.buses.get(busName) + if (bus === undefined) return this + bus.volume = clamp01(volume) + this.applyBus(busName) + return this + } + + setMute(busName: string, mute: boolean): this { + const bus = this.buses.get(busName) + if (bus === undefined) return this + bus.mute = mute + this.applyBus(busName) + return this + } + + setPan(busName: string, pan: number): this { + const bus = this.buses.get(busName) + if (bus === undefined) return this + bus.pan = clamp(pan, -1, 1) + this.applyBus(busName) + return this + } + + setListener(x: number, y: number): this { + this.listenerX = x + this.listenerY = y + return this + } + + play(busName: string, key: string, loop = true): void { + if (!this.buses.has(busName)) return + this.stopCurrentTrack(busName) + const sound = (this.scene.sound as any).add(key, { loop }) + sound.play() + this.currentMusic.set(busName, sound) + this.applyBus(busName) + this.emit(AUDIO_MUSIC_START, busName, key) + } + + crossfadeTo(busName: string, key: string, durationMs: number, loop = true): void { + if (!this.buses.has(busName)) return + const prevCf = this.crossfades.get(busName) + if (prevCf?.from !== null && prevCf?.from !== undefined) { + prevCf.from.stop(); prevCf.from.destroy() + } + const from = this.currentMusic.get(busName) ?? null + const fromVol = from !== null ? this.soundVolume(from) : 0 + const to = (this.scene.sound as any).add(key, { loop }) + to.play() + this.setSoundVolume(to, 0) + this.crossfades.set(busName, { from, fromStartVol: fromVol, to, durationMs, elapsed: 0 }) + this.currentMusic.set(busName, to) + this.emit(AUDIO_MUSIC_START, busName, key) + } + + stopMusic(busName: string, fadeDurationMs = 0): void { + const sound = this.currentMusic.get(busName) ?? null + if (sound === null) return + this.currentMusic.set(busName, null) + this.crossfades.delete(busName) + if (fadeDurationMs <= 0) { + sound.stop(); sound.destroy() + } else { + this.fadeOuts.set(busName, { + sound, + startVolume: this.soundVolume(sound), + durationMs: fadeDurationMs, + elapsed: 0 + }) + } + this.emit(AUDIO_MUSIC_END, busName) + } + + registerSfx(key: string, maxVoices = 4): this { + if (this.sfxPools.has(key)) return this + const sounds: any[] = [] + for (let i = 0; i < maxVoices; i++) { + sounds.push((this.scene.sound as any).add(key)) + } + this.sfxPools.set(key, { sounds, cursor: 0, maxVoices }) + return this + } + + playSfx(busName: string, key: string): void { + const bus = this.buses.get(busName) + if (bus === undefined) return + const entry = this.sfxPools.get(key) + if (entry === undefined) { + this.logger.warning(`sfx key "${key}" not registered — call registerSfx first`) + return + } + const sound = entry.sounds[entry.cursor] + entry.cursor = (entry.cursor + 1) % entry.maxVoices + if (sound.isPlaying) sound.stop() + this.setSoundVolume(sound, this.effectiveVolume(bus)) + this.setSoundPan(sound, bus.pan) + this.setSoundMute(sound, this.master.mute || bus.mute) + sound.play() + } + + playSpatial(busName: string, key: string, worldX: number, worldY: number, range = 400): void { + const bus = this.buses.get(busName) + if (bus === undefined) return + const dx = worldX - this.listenerX + const dy = worldY - this.listenerY + const distance = Math.sqrt(dx * dx + dy * dy) + const attenuation = Math.max(0, 1 - distance / range) + if (attenuation <= 0) return + const entry = this.sfxPools.get(key) + if (entry === undefined) { + this.logger.warning(`sfx key "${key}" not registered — call registerSfx first`) + return + } + const sound = entry.sounds[entry.cursor] + entry.cursor = (entry.cursor + 1) % entry.maxVoices + if (sound.isPlaying) sound.stop() + this.setSoundVolume(sound, this.effectiveVolume(bus) * attenuation) + this.setSoundPan(sound, clamp(dx / range, -1, 1)) + this.setSoundMute(sound, this.master.mute || bus.mute) + sound.play() + } + + duck(busName: string, duckVolume: number, holdMs: number, restoreDurationMs = 200): void { + const bus = this.buses.get(busName) + if (bus === undefined) return + const existing = this.ducks.get(busName) + const original = existing !== undefined ? existing.originalVolume : bus.volume + const duckVol = clamp01(duckVolume) + this.ducks.set(busName, { + originalVolume: original, + initialDuckVolume: duckVol, + currentDuckVolume: duckVol, + holdMs, + holdElapsed: 0, + restoreDurationMs, + restoreElapsed: 0 + }) + this.applyBus(busName) + } + + bindDebugger(dbg: Debugger): this { + const audioPanel = dbg.getPanel('audio') + if (audioPanel === null) return this + for (const [name, bus] of this.buses) { + audioPanel.addBus(name, (state: { volume: number, mute: boolean, pan: number }) => { + bus.volume = state.volume + bus.mute = state.mute + bus.pan = state.pan + this.applyBus(name) + this.emit(AUDIO_BUS_CHANGE, name, bus) + }) + } + return this + } + + private effectiveVolume(bus: Bus): number { + const duck = this.ducks.get(bus.name) + const busVol = duck !== undefined ? duck.currentDuckVolume : bus.volume + return this.master.volume * busVol + } + + private applyAll(): void { + for (const name of this.buses.keys()) this.applyBus(name) + } + + private applyBus(busName: string): void { + const bus = this.buses.get(busName) + if (bus === undefined) return + const vol = this.effectiveVolume(bus) + const mute = this.master.mute || bus.mute + const sound = this.currentMusic.get(busName) ?? null + if (sound !== null) { + this.setSoundVolume(sound, vol) + this.setSoundMute(sound, mute) + this.setSoundPan(sound, bus.pan) + } + } + + private stopCurrentTrack(busName: string): void { + const cf = this.crossfades.get(busName) + if (cf?.from !== null && cf?.from !== undefined) { + cf.from.stop(); cf.from.destroy() + } + this.crossfades.delete(busName) + const sound = this.currentMusic.get(busName) ?? null + if (sound !== null) { sound.stop(); sound.destroy() } + this.currentMusic.set(busName, null) + this.fadeOuts.delete(busName) + } + + private tickCrossfades(delta: number): void { + for (const [busName, cf] of this.crossfades) { + cf.elapsed += delta + const t = Math.min(cf.elapsed / cf.durationMs, 1) + const bus = this.buses.get(busName) + const targetVol = bus !== undefined ? this.effectiveVolume(bus) : 0 + const mute = bus !== undefined ? (this.master.mute || bus.mute) : false + const pan = bus?.pan ?? 0 + if (cf.from !== null) { + this.setSoundVolume(cf.from, cf.fromStartVol * (1 - t)) + this.setSoundMute(cf.from, mute) + } + this.setSoundVolume(cf.to, targetVol * t) + this.setSoundMute(cf.to, mute) + this.setSoundPan(cf.to, pan) + if (t >= 1) { + if (cf.from !== null) { cf.from.stop(); cf.from.destroy() } + this.crossfades.delete(busName) + } + } + } + + private tickFadeOuts(delta: number): void { + for (const [busName, fo] of this.fadeOuts) { + fo.elapsed += delta + const t = Math.min(fo.elapsed / fo.durationMs, 1) + this.setSoundVolume(fo.sound, fo.startVolume * (1 - t)) + if (t >= 1) { + fo.sound.stop(); fo.sound.destroy() + this.fadeOuts.delete(busName) + } + } + } + + private tickDucks(delta: number): void { + for (const [busName, duck] of this.ducks) { + duck.holdElapsed += delta + if (duck.holdElapsed < duck.holdMs) { + duck.currentDuckVolume = duck.initialDuckVolume + } else { + duck.restoreElapsed += delta + const t = Math.min(duck.restoreElapsed / duck.restoreDurationMs, 1) + duck.currentDuckVolume = duck.initialDuckVolume + (duck.originalVolume - duck.initialDuckVolume) * t + if (t >= 1) this.ducks.delete(busName) + } + this.applyBus(busName) + } + } + + private soundVolume(sound: any): number { + return typeof sound.volume === 'number' ? sound.volume : 1 + } + + private setSoundVolume(sound: any, volume: number): void { + if (typeof sound.setVolume === 'function') sound.setVolume(volume) + else if ('volume' in sound) sound.volume = volume + } + + private setSoundMute(sound: any, mute: boolean): void { + if (typeof sound.setMute === 'function') sound.setMute(mute) + else if ('mute' in sound) sound.mute = mute + } + + private setSoundPan(sound: any, pan: number): void { + if (typeof sound.setPan === 'function') sound.setPan(pan) + else if ('pan' in sound) sound.pan = pan + } + +} + +export default AudioFeature diff --git a/phaser-plus/src/audio/index.ts b/phaser-plus/src/audio/index.ts new file mode 100644 index 00000000..4e177a2a --- /dev/null +++ b/phaser-plus/src/audio/index.ts @@ -0,0 +1,12 @@ +import AudioFeature, { + AUDIO_MUSIC_START, + AUDIO_MUSIC_END, + AUDIO_BUS_CHANGE +} from './AudioFeature' + +export { + AudioFeature, + AUDIO_MUSIC_START, + AUDIO_MUSIC_END, + AUDIO_BUS_CHANGE +} diff --git a/phaser-plus/src/cinema/CameraDirector.ts b/phaser-plus/src/cinema/CameraDirector.ts index 8d786f91..d13117e2 100644 --- a/phaser-plus/src/cinema/CameraDirector.ts +++ b/phaser-plus/src/cinema/CameraDirector.ts @@ -1,6 +1,7 @@ import type { Cameras, GameObjects } from 'phaser' import Feature from '../features/Feature' import type Scene from '../engine/Scene' +import type ScreenShake from './ScreenShake' export type Easing = (t: number) => number @@ -65,6 +66,8 @@ export default class CameraDirector extends Feature { private camera: Cameras.Scene2D.Camera | null = null + private shake: ScreenShake | null = null + private queue: Shot[] = [] private active: Shot | null = null @@ -79,6 +82,15 @@ export default class CameraDirector extends Feature { private bound: boolean = false + /** Base camera position before the first timed cinematic shot started (excludes any shake offset). */ + private preScrollX: number = 0 + + private preScrollY: number = 0 + + private preZoom: number = 1 + + private hasPre: boolean = false + constructor(scene: Scene, key: string) { super(scene, key) } @@ -89,6 +101,23 @@ export default class CameraDirector extends Feature { return this } + /** + * Link a ScreenShake instance that shares the same camera so the director + * can cooperate with it instead of clobbering its additive offset. + * + * When linked, every absolute scroll write performed by the director + * includes the current shake offset on top of the tweened base position. + * startShot() also strips the shake offset from its startX/Y snapshot so + * the tween travels between true base positions rather than shake-polluted + * ones. Both orderings of the two features' onUpdate calls produce the + * same result: the camera always lands on tweenedBase + currentShakeOffset + * at the end of each frame. + */ + setShake(shake: ScreenShake): this { + this.shake = shake + return this + } + push(shot: Shot): this { this.queue.push(shot) return this @@ -103,6 +132,9 @@ export default class CameraDirector extends Feature { clear(): this { this.queue = [] this.active = null + if (this.hasPre && this.camera !== null) { + this.restorePreState(this.camera) + } return this } @@ -140,14 +172,36 @@ export default class CameraDirector extends Feature { } override onDestroy(): void { + if (this.hasPre && this.camera !== null) { + this.restorePreState(this.camera) + } this.queue = [] this.active = null } private startShot(shot: Shot, camera: Cameras.Scene2D.Camera): void { + const sx = this.shake + const shakeX = sx !== null ? sx.offsetX : 0 + const shakeY = sx !== null ? sx.offsetY : 0 + + if (shot.type === 'follow') { + // Follow is the "normal" camera mode — clear the cinematic snapshot so + // we do not restore to the pre-pan position when follow eventually ends. + this.hasPre = false + } else if (!this.hasPre) { + // First timed cinematic shot: remember where the camera was so we can + // restore it after the whole sequence is done. + this.preScrollX = camera.scrollX - shakeX + this.preScrollY = camera.scrollY - shakeY + this.preZoom = camera.zoom + this.hasPre = true + } + this.active = shot - this.startX = camera.scrollX - this.startY = camera.scrollY + // Store the BASE scroll position (without shake) so tween math operates on + // true world positions. tick* methods add the shake offset back on top. + this.startX = camera.scrollX - shakeX + this.startY = camera.scrollY - shakeY this.startZoom = camera.zoom this.elapsed = 0 } @@ -156,6 +210,20 @@ export default class CameraDirector extends Feature { const finished = this.active this.active = null if (finished !== null) this.emit(SHOT_DONE, finished) + // Restore camera to its pre-cinematic base position when the director + // runs out of shots, undoing any permanent scroll/zoom left by pan/zoom. + if (this.queue.length === 0 && this.hasPre && this.camera !== null) { + this.restorePreState(this.camera) + } + } + + private restorePreState(camera: Cameras.Scene2D.Camera): void { + if (!this.hasPre) return + const sx = this.shake + camera.scrollX = this.preScrollX + (sx !== null ? sx.offsetX : 0) + camera.scrollY = this.preScrollY + (sx !== null ? sx.offsetY : 0) + camera.zoom = this.preZoom + this.hasPre = false } private tickFollow(shot: FollowShot, camera: Cameras.Scene2D.Camera, _dt: number): void { @@ -174,8 +242,11 @@ export default class CameraDirector extends Feature { const e = ease(t) const targetX = shot.x - camera.width * 0.5 const targetY = shot.y - camera.height * 0.5 - camera.scrollX = this.startX + (targetX - this.startX) * e - camera.scrollY = this.startY + (targetY - this.startY) * e + const sx = this.shake + const shakeX = sx !== null ? sx.offsetX : 0 + const shakeY = sx !== null ? sx.offsetY : 0 + camera.scrollX = this.startX + (targetX - this.startX) * e + shakeX + camera.scrollY = this.startY + (targetY - this.startY) * e + shakeY if (t >= 1) this.completeActive() } @@ -201,9 +272,12 @@ export default class CameraDirector extends Feature { const cy = shot.y + shot.height * 0.5 const targetScrollX = cx - (camera.width * 0.5) / targetZoom const targetScrollY = cy - (camera.height * 0.5) / targetZoom + const sx = this.shake + const shakeX = sx !== null ? sx.offsetX : 0 + const shakeY = sx !== null ? sx.offsetY : 0 camera.zoom = this.startZoom + (targetZoom - this.startZoom) * e - camera.scrollX = this.startX + (targetScrollX - this.startX) * e - camera.scrollY = this.startY + (targetScrollY - this.startY) * e + camera.scrollX = this.startX + (targetScrollX - this.startX) * e + shakeX + camera.scrollY = this.startY + (targetScrollY - this.startY) * e + shakeY if (t >= 1) this.completeActive() } @@ -218,8 +292,11 @@ export default class CameraDirector extends Feature { const ease = shot.ease ?? EASE_LINEAR const e = ease(t) const point = catmullRom(shot.points, e) - camera.scrollX = point.x - camera.width * 0.5 - camera.scrollY = point.y - camera.height * 0.5 + const sx = this.shake + const shakeX = sx !== null ? sx.offsetX : 0 + const shakeY = sx !== null ? sx.offsetY : 0 + camera.scrollX = point.x - camera.width * 0.5 + shakeX + camera.scrollY = point.y - camera.height * 0.5 + shakeY if (!shot.loop && t >= 1) this.completeActive() } diff --git a/phaser-plus/src/cinema/DialogCameraCue.ts b/phaser-plus/src/cinema/DialogCameraCue.ts index 40dfe0ba..daebae1b 100644 --- a/phaser-plus/src/cinema/DialogCameraCue.ts +++ b/phaser-plus/src/cinema/DialogCameraCue.ts @@ -98,6 +98,7 @@ export default class DialogCameraCue extends Feature { if (this.state === 'closed') return this this.elapsed = (1 - this.alphaT) * this.fadeSec this.state = 'closing' + if (this.blurEnabled) this.toggleBlur(false) return this } @@ -230,7 +231,6 @@ export default class DialogCameraCue extends Feature { if (this.state === 'closed') { for (const r of this.panels) r.setVisible(false) for (const r of this.vignettePanels) r.setVisible(false) - if (this.blurEnabled) this.toggleBlur(false) } } diff --git a/phaser-plus/src/cinema/ScreenShake.ts b/phaser-plus/src/cinema/ScreenShake.ts index c7066640..3d62b167 100644 --- a/phaser-plus/src/cinema/ScreenShake.ts +++ b/phaser-plus/src/cinema/ScreenShake.ts @@ -27,14 +27,23 @@ export default class ScreenShake extends Feature { private exponent: number = 2 - private appliedX: number = 0 + private _appliedX: number = 0 - private appliedY: number = 0 + private _appliedY: number = 0 - private appliedRotation: number = 0 + private _appliedRotation: number = 0 private bound: boolean = false + /** Current shake offset on the X axis (world pixels). Read by CameraDirector to cooperate on the same camera. */ + get offsetX(): number { return this._appliedX } + + /** Current shake offset on the Y axis (world pixels). Read by CameraDirector to cooperate on the same camera. */ + get offsetY(): number { return this._appliedY } + + /** Current shake rotation offset (radians). Read by CameraDirector to cooperate on the same camera. */ + get offsetRotation(): number { return this._appliedRotation } + constructor(scene: Scene, key: string) { super(scene, key) } @@ -107,9 +116,9 @@ export default class ScreenShake extends Feature { if (camera === null) return const dt = delta / 1000 - camera.scrollX -= this.appliedX - camera.scrollY -= this.appliedY - camera.rotation -= this.appliedRotation + camera.scrollX -= this._appliedX + camera.scrollY -= this._appliedY + camera.rotation -= this._appliedRotation const survivors: ShakeSource[] = [] let randomTotal = 0 @@ -134,9 +143,9 @@ export default class ScreenShake extends Feature { this.sources = survivors if (survivors.length === 0) { - this.appliedX = 0 - this.appliedY = 0 - this.appliedRotation = 0 + this._appliedX = 0 + this._appliedY = 0 + this._appliedRotation = 0 return } @@ -151,21 +160,21 @@ export default class ScreenShake extends Feature { randomRot = (Math.random() * 2 - 1) * this.maxAngle * shake } - this.appliedX = randomX + sineX - this.appliedY = randomY + sineY - this.appliedRotation = randomRot + sineRot + this._appliedX = randomX + sineX + this._appliedY = randomY + sineY + this._appliedRotation = randomRot + sineRot - camera.scrollX += this.appliedX - camera.scrollY += this.appliedY - camera.rotation += this.appliedRotation + camera.scrollX += this._appliedX + camera.scrollY += this._appliedY + camera.rotation += this._appliedRotation } override onDestroy(): void { this.sources = [] if (this.camera !== null) { - this.camera.scrollX -= this.appliedX - this.camera.scrollY -= this.appliedY - this.camera.rotation -= this.appliedRotation + this.camera.scrollX -= this._appliedX + this.camera.scrollY -= this._appliedY + this.camera.rotation -= this._appliedRotation } this.camera = null } diff --git a/phaser-plus/src/debugger/Debugger.ts b/phaser-plus/src/debugger/Debugger.ts index 1e67acd9..6c922019 100644 --- a/phaser-plus/src/debugger/Debugger.ts +++ b/phaser-plus/src/debugger/Debugger.ts @@ -23,6 +23,8 @@ export default class Debugger extends HTMLFeature { private readonly panels: Record = {} + private readonly folders: Record = {} + private updateLoop!: Time.TimerEvent override onCreate(): void { @@ -103,6 +105,7 @@ export default class Debugger extends HTMLFeature { throw new Error(`panel key=${key} is already taken`) } const folder = (this.pane as any).addFolder({ title: title === null ? key : title }) + this.folders[key] = folder const panel = new panelClass(this.scene, folder) panel.draw() this.panels[key] = panel @@ -117,6 +120,8 @@ export default class Debugger extends HTMLFeature { if (!panel) return this panel.dispose() delete this.panels[key] + this.folders[key]?.dispose?.() + delete this.folders[key] return this } diff --git a/phaser-plus/src/debugger/index.ts b/phaser-plus/src/debugger/index.ts index 1f22c266..91102bba 100644 --- a/phaser-plus/src/debugger/index.ts +++ b/phaser-plus/src/debugger/index.ts @@ -6,6 +6,7 @@ import TimelinePanel from './panels/TimelinePanel' import InputPanel from './panels/InputPanel' import AudioPanel from './panels/AudioPanel' import NetPanel from './panels/NetPanel' +import SaveStatePanel from './panels/SaveStatePanel' import ConsoleCommands from './tools/ConsoleCommands' import HotReload from './tools/HotReload' import RemoteDebugger from './tools/RemoteDebugger' @@ -19,6 +20,7 @@ export { InputPanel, AudioPanel, NetPanel, + SaveStatePanel, ConsoleCommands, HotReload, RemoteDebugger diff --git a/phaser-plus/src/debugger/panels/AudioPanel.ts b/phaser-plus/src/debugger/panels/AudioPanel.ts index 00764995..5f8afc7a 100644 --- a/phaser-plus/src/debugger/panels/AudioPanel.ts +++ b/phaser-plus/src/debugger/panels/AudioPanel.ts @@ -15,6 +15,16 @@ interface BusState { channels: string[] } +/** + * Debugger panel for the Phaser sound system. + * + * The bus API (`addBus` / `addChannel` / `removeChannel` / `removeBus` / `getBus`) + * is the intended bind surface for the future `AudioFeature`. Until that subsystem + * ships, callers wire buses manually by calling these methods themselves — typically + * once during scene `onCreate`. This is deliberate: the hooks are ready so that + * dropping in `AudioFeature` later requires only removing the manual wiring, not + * changing the panel contract. + */ export default class AudioPanel extends Panel { state: AudioState = { @@ -32,6 +42,8 @@ export default class AudioPanel extends Panel { private busListeners: Record void> = {} + private busSeenSounds: Record> = {} + override draw(): void { const sound = this.scene.sound as any this.state.masterVolume = typeof sound.volume === 'number' ? sound.volume : 1 @@ -47,16 +59,30 @@ export default class AudioPanel extends Panel { this.components.playing = this.base.addBinding(this.state, 'playing', { readonly: true, label: 'Playing' }) } + /** + * Register a named mixer bus in the panel. + * + * **Manual-instrumentation hook.** This is the bind point that `AudioFeature` + * will call automatically once it ships. Until then, call it yourself during + * scene `onCreate` to create bus controls, then use `addChannel` to assign + * sound keys to the bus. The `onChange` callback fires whenever the user + * adjusts the bus sliders, so you can mirror the values onto your own audio + * objects without waiting for `AudioFeature`. + * + * Returns the existing `BusState` unchanged if a bus with the same name was + * already registered. + */ addBus(name: string, onChange?: (bus: BusState) => void): BusState { if (this.buses[name] !== undefined) return this.buses[name] const bus: BusState = { name, volume: 1, mute: false, pan: 0, channels: [] } const folder = this.base.addFolder({ title: `Bus: ${name}` }) - const apply = () => { this.applyBus(bus); onChange?.(bus) } + const apply = () => { this.applyBus(bus, true); onChange?.(bus) } folder.addBinding(bus, 'volume', { label: 'Vol', min: 0, max: 1, step: 0.01 }).on('change', apply) folder.addBinding(bus, 'mute', { label: 'Mute' }).on('change', apply) folder.addBinding(bus, 'pan', { label: 'Pan', min: -1, max: 1, step: 0.01 }).on('change', apply) this.buses[name] = bus this.busFolders[name] = folder + this.busSeenSounds[name] = new Set() if (onChange) this.busListeners[name] = onChange return bus } @@ -92,6 +118,7 @@ export default class AudioPanel extends Panel { } delete this.buses[name] delete this.busListeners[name] + delete this.busSeenSounds[name] return this } @@ -99,16 +126,19 @@ export default class AudioPanel extends Panel { return this.buses[name] ?? null } - private applyBus(bus: BusState): void { + private applyBus(bus: BusState, force = false): void { const sound = this.scene.sound as any const list: any[] = sound.sounds ?? [] + const seen = this.busSeenSounds[bus.name] for (const s of list) { if (s == null || !bus.channels.includes(s.key)) continue + if (!force && seen?.has(s)) continue if (typeof s.setVolume === 'function') s.setVolume(bus.volume) else if ('volume' in s) s.volume = bus.volume if (typeof s.setMute === 'function') s.setMute(bus.mute) else if ('mute' in s) s.mute = bus.mute this.setPan(s, bus.pan) + seen?.add(s) } } @@ -133,8 +163,13 @@ export default class AudioPanel extends Panel { if (s?.isPlaying) playing.push(s.key ?? '?') } this.state.playing = playing.length === 0 ? '(none)' : playing.join(' ') - // Re-apply each bus so channels that start playing later inherit the mix. - for (const name in this.buses) this.applyBus(this.buses[name]) + // Prune dead references then apply buses only to newly appearing sounds. + const liveSet = new Set(list) + for (const name in this.buses) { + const seen = this.busSeenSounds[name] + if (seen) for (const s of seen) if (!liveSet.has(s)) seen.delete(s) + this.applyBus(this.buses[name]) + } for (const key in this.components) this.components[key].refresh?.() } @@ -145,6 +180,7 @@ export default class AudioPanel extends Panel { this.buses = {} this.busFolders = {} this.busListeners = {} + this.busSeenSounds = {} } } diff --git a/phaser-plus/src/debugger/panels/InspectorPanel.ts b/phaser-plus/src/debugger/panels/InspectorPanel.ts index a123174b..b7d53e26 100644 --- a/phaser-plus/src/debugger/panels/InspectorPanel.ts +++ b/phaser-plus/src/debugger/panels/InspectorPanel.ts @@ -96,4 +96,16 @@ export default class InspectorPanel extends Panel { setTimeout(() => URL.revokeObjectURL(url), 1000) } + override dispose(): void { + if (this.recorder !== null) { + this.recorder.ondataavailable = null + this.recorder.onstop = null + try { if (this.recorder.state !== 'inactive') this.recorder.stop() } catch {} + this.recorder = null + } + this.videoChunks = [] + this.state.recording = false + super.dispose() + } + } diff --git a/phaser-plus/src/debugger/panels/NetPanel.ts b/phaser-plus/src/debugger/panels/NetPanel.ts index 5bcd665d..c2913a23 100644 --- a/phaser-plus/src/debugger/panels/NetPanel.ts +++ b/phaser-plus/src/debugger/panels/NetPanel.ts @@ -10,6 +10,15 @@ interface NetState { const MAX_LOG = 50 +/** + * Debugger panel for network telemetry. + * + * The reporting API (`reportRTT`, `reportLoss`, `sent`, `received`, `logMessage`) + * is the intended bind surface for the future `NetFeature`. Until that subsystem + * ships, callers push metrics manually — typically from a WebSocket message handler + * or a ping loop. The panel contract is stable so that wiring in `NetFeature` later + * only removes the manual calls, not any panel-side changes. + */ export default class NetPanel extends Panel { state: NetState = { @@ -39,22 +48,48 @@ export default class NetPanel extends Panel { this.components.clear = this.base.addButton({ title: 'Clear log' }).on('click', () => this.clear()) } + /** + * Push the current round-trip time. + * + * **Manual-instrumentation hook** — intended to be called by `NetFeature` + * once it ships. Until then, call this yourself from a ping response handler. + */ reportRTT(ms: number): this { this.state.rtt = Math.round(ms) return this } + /** + * Push the current packet-loss percentage (0–100). + * + * **Manual-instrumentation hook** — intended to be called by `NetFeature` + * once it ships. + */ reportLoss(pct: number): this { this.state.loss = Math.round(pct * 100) / 100 return this } + /** + * Record one outbound message. Pass `label` to append a log entry. + * + * **Manual-instrumentation hook** — intended to be called by `NetFeature` + * once it ships. Until then, call this yourself immediately after each + * `socket.send()` (or equivalent). + */ sent(label: string = ''): this { this.sentBucket += 1 if (label !== '') this.logMessage(`→ ${label}`) return this } + /** + * Record one inbound message. Pass `label` to append a log entry. + * + * **Manual-instrumentation hook** — intended to be called by `NetFeature` + * once it ships. Until then, call this yourself from your socket `message` + * event handler. + */ received(label: string = ''): this { this.recvBucket += 1 if (label !== '') this.logMessage(`← ${label}`) diff --git a/phaser-plus/src/debugger/panels/SaveStatePanel.ts b/phaser-plus/src/debugger/panels/SaveStatePanel.ts new file mode 100644 index 00000000..6808cce5 --- /dev/null +++ b/phaser-plus/src/debugger/panels/SaveStatePanel.ts @@ -0,0 +1,100 @@ +import type SaveService from '../../persistence/SaveService' +import type { SaveEntry } from '../../persistence/SaveService' +import Panel from '../Panel' + +export default class SaveStatePanel extends Panel { + + private service: SaveService | null = null + private entries: SaveEntry[] = [] + private refreshing = false + + state = { + slots: '(no service)', + version: '', + savedAt: '', + preview: '' + } + + components: Record = {} + + override draw(): void { + this.components.slots = this.base.addBinding(this.state, 'slots', { readonly: true, label: 'Slots' }) + this.components.slotId = this.base.addBlade({ view: 'text', label: 'Slot ID', parse: (v: string) => v, value: '' }) + this.components.version = this.base.addBinding(this.state, 'version', { readonly: true, label: 'Version' }) + this.components.savedAt = this.base.addBinding(this.state, 'savedAt', { readonly: true, label: 'Saved' }) + this.components.preview = this.base.addBinding(this.state, 'preview', { readonly: true, label: 'Data' }) + this.base.addBlade({ view: 'separator' }) + this.base.addButton({ title: 'Refresh' }).on('click', () => this.refresh()) + this.base.addButton({ title: 'Inspect' }).on('click', () => this.inspectSelected()) + this.base.addButton({ title: 'Force Load' }).on('click', () => this.forceLoad()) + this.base.addButton({ title: 'Delete Slot' }).on('click', () => this.deleteSlot()) + } + + bind(service: SaveService): this { + this.service = service + this.refresh() + return this + } + + refresh(): void { + if (!this.service || this.refreshing) return + this.refreshing = true + this.service.list().then(entries => { + this.entries = entries + this.state.slots = entries.length === 0 ? '(empty)' : entries.map(e => e.id).join(', ') + if (!this.components.slotId?.value && entries.length > 0) { + this.components.slotId.value = entries[0].id + } + this.syncMeta() + this.refreshing = false + }).catch(() => { this.refreshing = false }) + } + + private inspectSelected(): void { + this.syncMeta() + if (!this.service) return + const id: string = this.components.slotId?.value ?? '' + if (!id) return + this.service.load(id).then(data => { + this.state.preview = data !== null ? JSON.stringify(data).slice(0, 120) : '(null)' + }).catch(() => { this.state.preview = '(error)' }) + } + + private syncMeta(): void { + const id: string = this.components.slotId?.value ?? '' + const entry = this.entries.find(e => e.id === id) + if (!entry) { + this.state.version = '' + this.state.savedAt = '' + this.state.preview = '' + return + } + this.state.version = `v${entry.version}` + this.state.savedAt = new Date(entry.savedAt).toISOString().slice(0, 19).replace('T', ' ') + } + + private forceLoad(): void { + if (!this.service) return + const id: string = this.components.slotId?.value ?? '' + if (!id) return + this.service.load(id).then(data => { + this.emit('load', id, data) + }).catch(() => {}) + } + + private deleteSlot(): void { + if (!this.service) return + const id: string = this.components.slotId?.value ?? '' + if (!id) return + this.service.delete(id).then(() => { + this.emit('delete', id) + if (this.components.slotId) this.components.slotId.value = '' + this.refresh() + }).catch(() => {}) + } + + override doUpdate(): void { + for (const key in this.components) this.components[key].refresh?.() + } + +} diff --git a/phaser-plus/src/debugger/panels/TimelinePanel.ts b/phaser-plus/src/debugger/panels/TimelinePanel.ts index 27165923..acabbc50 100644 --- a/phaser-plus/src/debugger/panels/TimelinePanel.ts +++ b/phaser-plus/src/debugger/panels/TimelinePanel.ts @@ -1,4 +1,6 @@ import Panel from '../Panel' +import type { TimelineHandle } from '../../flow/TweenProcessor' +import ReplayRecorder, { REPLAY_FRAME, REPLAY_END } from '../../flow/ReplayRecorder' interface TimelineEntry { time: number @@ -11,6 +13,11 @@ interface TimelineState { log: string cursor: number historyLen: number + tlScrub: number + tlProgress: string + rpState: string + rpFrame: string + rpScrub: number } const MAX_HISTORY = 200 @@ -22,14 +29,27 @@ export default class TimelinePanel extends Panel { paused: false, log: '', cursor: 0, - historyLen: 0 + historyLen: 0, + tlScrub: 0, + tlProgress: '--', + rpState: 'idle', + rpFrame: '--', + rpScrub: 0 } components: Record = {} private history: TimelineEntry[] = [] - private originalTrigger: ((event: string, payload: unknown, delay?: number) => void) | null = null + private originalTrigger: ((event: string, payload: unknown, delay?: number) => unknown) | null = null + + private _boundHandle: TimelineHandle | null = null + + private _boundRecorder: ReplayRecorder | null = null + + private _onReplayFrame: ((...args: any[]) => void) | null = null + + private _onReplayEnd: ((...args: any[]) => void) | null = null override draw(): void { this.components.paused = this.base.addBinding(this.state, 'paused', { label: 'Paused' }) @@ -42,13 +62,103 @@ export default class TimelinePanel extends Panel { this.hookFlow() } + bindTimeline(handle: TimelineHandle): void { + this._boundHandle = handle + this.state.tlScrub = 0 + this.components.tlScrub = this.base.addBinding(this.state, 'tlScrub', { + label: 'TL Scrub', + min: 0, + max: 1, + step: 0.001 + }) + this.components.tlScrub.on('change', (e: { value: number }) => { + this._boundHandle?.seek(this._boundHandle.total * e.value) + }) + this.components.tlProgress = this.base.addBinding(this.state, 'tlProgress', { + readonly: true, + label: 'TL Progress' + }) + this.components.tlReplay = this.base.addButton({ title: 'Replay TL' }).on('click', () => { + this._boundHandle?.restart() + }) + this.components.tlPause = this.base.addButton({ title: 'Pause TL' }).on('click', () => { + if (this._boundHandle?.paused) { + this._boundHandle.resume() + } else { + this._boundHandle?.pause() + } + }) + } + + /** + * Bind a `ReplayRecorder` to this panel. Adds RP State / RP Frame readouts, + * a scrub slider (seeks frame during playback), and Record / Stop / Replay + * buttons. Each recorded `REPLAY_FRAME` is also appended to the history log. + */ + bindReplay(recorder: ReplayRecorder): void { + this._boundRecorder = recorder + + this.state.rpState = recorder.state + this.state.rpFrame = '--' + this.state.rpScrub = 0 + + this.components.rpState = this.base.addBinding(this.state, 'rpState', { + readonly: true, + label: 'RP State' + }) + this.components.rpFrame = this.base.addBinding(this.state, 'rpFrame', { + readonly: true, + label: 'RP Frame' + }) + this.components.rpScrub = this.base.addBinding(this.state, 'rpScrub', { + label: 'RP Scrub', + min: 0, + max: 1, + step: 0.001 + }) + this.components.rpScrub.on('change', (e: { value: number }) => { + const session = this._boundRecorder?.session + if (!session || session.frames.length === 0) return + this._boundRecorder?.seekTo(Math.floor(e.value * session.frames.length)) + }) + this.components.rpRecord = this.base.addButton({ title: 'RP Record' }).on('click', () => { + this._boundRecorder?.record() + }) + this.components.rpStop = this.base.addButton({ title: 'RP Stop' }).on('click', () => { + this._boundRecorder?.stop() + }) + this.components.rpReplay = this.base.addButton({ title: 'RP Replay' }).on('click', () => { + const session = this._boundRecorder?.session + if (!session || session.frames.length === 0) return + this._boundRecorder?.play(session) + }) + + this._onReplayFrame = (tick: number) => { + this.push({ time: (this.game as any).loop?.time ?? 0, type: 'replay', name: `frame:${tick}` }) + } + this._onReplayEnd = () => { + this.push({ time: (this.game as any).loop?.time ?? 0, type: 'replay', name: 'end' }) + } + + this.scene.features.on(REPLAY_FRAME, this._onReplayFrame) + this.scene.features.on(REPLAY_END, this._onReplayEnd) + } + private hookFlow(): void { - const events = this.scene.flow?.events as unknown as { trigger: (e: string, p: unknown, d?: number) => void } | undefined - if (!events) return + const events: any = this.scene.flow?.events + if (!events || events.__hooked) return + events.__hooked = true this.originalTrigger = events.trigger.bind(events) events.trigger = (event: string, payload: unknown, delay?: number) => { this.push({ time: ((this.game as any).loop?.time ?? 0), type: 'event', name: event }) - this.originalTrigger!(event, payload, delay) + return this.originalTrigger!(event, payload, delay) + } + + const tweens: any = this.scene.flow?.tweens + if (tweens && tweens.onLog === null) { + tweens.onLog = (time: number, type: string, name: string) => { + this.push({ time, type, name }) + } } } @@ -85,15 +195,53 @@ export default class TimelinePanel extends Panel { override doUpdate(): void { if (this.components.cursor) this.components.cursor.max = Math.max(0, this.history.length - 1) this.refreshLog() + if (this._boundHandle !== null) { + const h = this._boundHandle + this.state.tlProgress = `${(h.progress * 100).toFixed(1)}% (${h.elapsed.toFixed(0)}ms)` + if (!h.paused && !h.completed) { + this.state.tlScrub = h.progress + } + this.components.tlProgress?.refresh?.() + } + if (this._boundRecorder !== null) { + const r = this._boundRecorder + this.state.rpState = r.state + const session = r.session + const total = session?.frames.length ?? 0 + this.state.rpFrame = total > 0 + ? `${Math.min(r.tick, total)} / ${total}` + : '--' + if (total > 0) { + this.state.rpScrub = r.tick / total + } else { + this.state.rpScrub = 0 + } + this.components.rpState?.refresh?.() + this.components.rpFrame?.refresh?.() + this.components.rpScrub?.refresh?.() + } for (const key in this.components) this.components[key].refresh?.() } override dispose(): void { - const events = this.scene.flow?.events as unknown as { trigger: (e: string, p: unknown, d?: number) => void } | undefined + const events: any = this.scene.flow?.events if (events && this.originalTrigger !== null) { - events.trigger = this.originalTrigger + delete events.trigger + delete events.__hooked this.originalTrigger = null } + const tweens: any = this.scene.flow?.tweens + if (tweens) tweens.onLog = null + this._boundHandle = null + if (this._onReplayFrame !== null) { + this.scene.features.off(REPLAY_FRAME, this._onReplayFrame) + this._onReplayFrame = null + } + if (this._onReplayEnd !== null) { + this.scene.features.off(REPLAY_END, this._onReplayEnd) + this._onReplayEnd = null + } + this._boundRecorder = null } } diff --git a/phaser-plus/src/effects/EffectManager.ts b/phaser-plus/src/effects/EffectManager.ts index 27be50ae..01a0419a 100644 --- a/phaser-plus/src/effects/EffectManager.ts +++ b/phaser-plus/src/effects/EffectManager.ts @@ -38,7 +38,12 @@ export default class EffectManager { const list = this.filterList const camera = list.camera as Cameras.Scene2D.Camera const game = (this.target as any).scene?.game ?? camera.scene?.game - if (game) ensureEffectRegistered(game, EffectClass) + if (game) { + const registered = ensureEffectRegistered(game, EffectClass) + if (!registered) { + throw new Error(`reef.effects: effect ${EffectClass.KEY} could not be registered (WebGL renderer not booted?)`) + } + } const effect = new EffectClass(camera) if (params) { for (const key of Object.keys(params) as Array) { diff --git a/phaser-plus/src/effects/shaders/distortionEffects.ts b/phaser-plus/src/effects/shaders/distortionEffects.ts index 6551689b..81d748d0 100644 --- a/phaser-plus/src/effects/shaders/distortionEffects.ts +++ b/phaser-plus/src/effects/shaders/distortionEffects.ts @@ -126,7 +126,9 @@ export class DistortionAdditiveEffect extends DistortionEffect { } // -------------------------------------------------------------------------- -// Wave — alias of Distortion preserved for parity with effects.md. +// Wave — intentional alias of DistortionEffect registered under a separate KEY +// for API parity with effects.md. Shares DistortionEffect.FRAGMENT exactly; +// visual behaviour is identical. Prefer DistortionEffect for new code. // -------------------------------------------------------------------------- export class WaveEffect extends DistortionEffect { static readonly KEY: string = 'reef.Wave' @@ -178,8 +180,33 @@ export class MysticDistortionEffect extends Effect { } } +// -------------------------------------------------------------------------- +// MysticDistortionAdditive — additive-blend variant of MysticDistortionEffect. +// Brightens the distorted output (×1.6, clamped) so it composes correctly +// under Phaser's additive blend mode, mirroring DistortionAdditiveEffect. +// -------------------------------------------------------------------------- export class MysticDistortionAdditiveEffect extends MysticDistortionEffect { static readonly KEY: string = 'reef.MysticDistortionAdditive' + static readonly FRAGMENT: string = `${HEAD}${SAFE_SAMPLE} + uniform vec2 uOffset; + uniform vec2 uDistance; + uniform vec2 uPhase; + uniform float uPitch; + uniform float uPitchSpeed; + uniform float uPitchOffset; + uniform vec3 uGlow; + void main() { + vec2 d = vec2( + sin(outTexCoord.y * uOffset.x + uPhase.x) * uDistance.x, + sin(outTexCoord.x * uOffset.y + uPhase.y) * uDistance.y + ); + float band = sin(outTexCoord.y * 90.0 + uTime * uPitchSpeed * 1.4 + uPitchOffset); + d.x += band * uPitch * 0.05; + vec4 src = safeSample(outTexCoord + d); + float aura = smoothstep(0.0, 0.6, abs(band)) * 0.3; + vec3 outRgb = clamp((src.rgb + uGlow * aura) * 1.6, 0.0, 1.0); + ${MIX_OUT} + }` } // -------------------------------------------------------------------------- @@ -246,6 +273,11 @@ export class JellyEffect extends Effect { } } +// -------------------------------------------------------------------------- +// JellyAutoMove — intentional alias of JellyEffect registered under a separate +// KEY for API parity. Shares JellyEffect.FRAGMENT exactly; visual behaviour is +// identical. Differentiate at call-site by supplying distinct uniform values. +// -------------------------------------------------------------------------- export class JellyAutoMoveEffect extends JellyEffect { static readonly KEY: string = 'reef.JellyAutoMove' } @@ -319,6 +351,11 @@ export class LiquifyEffect extends Effect { } } +// -------------------------------------------------------------------------- +// Slim — intentional alias of LiquifyEffect registered under a separate KEY +// for API parity. Shares LiquifyEffect.FRAGMENT exactly; visual behaviour is +// identical. Differentiate at call-site by supplying distinct uniform values. +// -------------------------------------------------------------------------- export class SlimEffect extends LiquifyEffect { static readonly KEY: string = 'reef.Slim' } diff --git a/phaser-plus/src/effects/shaders/lightingEffects.ts b/phaser-plus/src/effects/shaders/lightingEffects.ts index 4f3e035d..495b4b1a 100644 --- a/phaser-plus/src/effects/shaders/lightingEffects.ts +++ b/phaser-plus/src/effects/shaders/lightingEffects.ts @@ -6,7 +6,7 @@ import { HEAD, HASH, NOISE, SAMPLE_SRC, SAFE_SAMPLE, MIX_OUT } from './_prelude' // -------------------------------------------------------------------------- export class OutlineEffect extends Effect { static readonly KEY = 'reef.Outline' - static readonly FRAGMENT = `${HEAD} + static readonly FRAGMENT = `${HEAD}${SAFE_SAMPLE} uniform float uSpread; uniform float uSoftness; uniform vec3 uColor; @@ -20,7 +20,7 @@ export class OutlineEffect extends Effect { for (int x = -1; x <= 1; x++) { for (int y = -1; y <= 1; y++) { if (x == 0 && y == 0) continue; - a = max(a, texture2D(uMainSampler, outTexCoord + vec2(float(x), float(y)) * uSpread).a); + a = max(a, safeSample(outTexCoord + vec2(float(x), float(y)) * uSpread).a); } } float edge = smoothstep(0.0, uSoftness, a) * (1.0 - src.a); @@ -70,8 +70,24 @@ export class PatternEffect extends Effect { } } +// -------------------------------------------------------------------------- +// PatternAdditive — additive-blend variant of PatternEffect. Brightens the +// tiled pattern output (×1.6, clamped) so it composes correctly under Phaser's +// additive blend mode, mirroring DistortionAdditiveEffect. +// -------------------------------------------------------------------------- export class PatternAdditiveEffect extends PatternEffect { static readonly KEY: string = 'reef.PatternAdditive' + static readonly FRAGMENT: string = `${HEAD} + uniform vec2 uTile; + uniform vec2 uScroll; + uniform vec3 uColor; + void main() { + ${SAMPLE_SRC} + vec2 tiled = fract(outTexCoord / uTile + uScroll * uTime); + vec4 patSrc = texture2D(uMainSampler, tiled); + vec3 outRgb = clamp(mix(src.rgb, patSrc.rgb * uColor, src.a) * 1.6, 0.0, 1.0); + ${MIX_OUT} + }` } // -------------------------------------------------------------------------- @@ -79,7 +95,7 @@ export class PatternAdditiveEffect extends PatternEffect { // -------------------------------------------------------------------------- export class EdgeColorEffect extends Effect { static readonly KEY = 'reef.EdgeColor' - static readonly FRAGMENT = `${HEAD} + static readonly FRAGMENT = `${HEAD}${SAFE_SAMPLE} uniform vec3 uColor; uniform float uWidth; void main() { @@ -88,7 +104,7 @@ export class EdgeColorEffect extends Effect { float minA = 1.0; for (int x = -1; x <= 1; x++) { for (int y = -1; y <= 1; y++) { - minA = min(minA, texture2D(uMainSampler, outTexCoord + vec2(float(x), float(y)) * px).a); + minA = min(minA, safeSample(outTexCoord + vec2(float(x), float(y)) * px).a); } } float edge = src.a * (1.0 - minA); @@ -129,7 +145,7 @@ export class BlurEffect extends Effect { acc += texture2D(uMainSampler, outTexCoord - vec2(0.0, float(i)) * px) * weights[i]; } vec4 src = texture2D(uMainSampler, outTexCoord); - vec3 outRgb = (acc / 1.74).rgb; + vec3 outRgb = (acc / 1.77297).rgb; ${MIX_OUT} }` alpha: number = 1 @@ -429,8 +445,36 @@ export class WaterAndBackgroundEffect extends Effect { } } +// -------------------------------------------------------------------------- +// WaterAndBackgroundDeluxe — enhanced variant of WaterAndBackgroundEffect with +// foam highlights, depth-graded tint, and a horizon glow on the upper half for +// a richer scene. Distinct FRAGMENT; use WaterAndBackground for the lighter look. +// -------------------------------------------------------------------------- export class WaterAndBackgroundDeluxeEffect extends WaterAndBackgroundEffect { static readonly KEY: string = 'reef.WaterAndBackgroundDeluxe' + static readonly FRAGMENT: string = `${HEAD}${HASH}${NOISE}${SAFE_SAMPLE} + uniform float uHeat; + uniform float uSpeed; + uniform float uLight; + void main() { + vec2 uv = outTexCoord; + if (uv.y > 0.5) { + vec2 q = uv * 6.0 + uTime * uSpeed * vec2(0.4, 0.6); + vec2 d = (vec2(fbm(q), fbm(q + 1.7)) - 0.5) * 0.05 * uHeat; + vec2 reflected = vec2(uv.x, 1.0 - uv.y) + d; + vec4 src = safeSample(reflected); + float depth = (uv.y - 0.5) * 2.0; + float caustic = pow(0.5 + 0.5 * sin((uv.y - 0.5) * 30.0 + uTime * 2.0 + d.x * 20.0), 2.0); + float foam = smoothstep(0.7, 1.0, fbm(q * 2.5 + uTime * 0.5)) * (1.0 - depth) * 0.4; + vec3 tint = mix(vec3(0.45, 0.75, 0.95), vec3(0.1, 0.3, 0.6), depth); + vec3 outRgb = src.rgb * tint + caustic * vec3(0.10) * uLight + foam; + gl_FragColor = vec4(clamp(mix(src.rgb, outRgb, uAlpha), 0.0, 1.0), src.a); + return; + } + vec4 src = safeSample(uv); + float horizon = smoothstep(0.48, 0.5, uv.y) * 0.15 * uLight; + gl_FragColor = vec4(clamp(src.rgb + vec3(0.45, 0.75, 0.95) * horizon, 0.0, 1.0), src.a); + }` } // -------------------------------------------------------------------------- diff --git a/phaser-plus/src/effects/shaders/maskEffects.ts b/phaser-plus/src/effects/shaders/maskEffects.ts index e6a11a94..c0e00c5a 100644 --- a/phaser-plus/src/effects/shaders/maskEffects.ts +++ b/phaser-plus/src/effects/shaders/maskEffects.ts @@ -76,7 +76,7 @@ export class EnergyBarEffect extends Effect { float fill = step(0.0, dist); float edge = smoothstep(0.04, 0.0, abs(dist)); float pulse = 0.6 + 0.4 * sin(uTime * 6.0); - float seg = step(0.5, abs(fract(outTexCoord.x * uSegments) - 0.5)) * 0.05; + float seg = step(abs(fract(outTexCoord.x * uSegments) - 0.5), 0.04) * 0.05; vec3 fillCol = uColor * (1.0 - seg); vec3 outRgb = mix(src.rgb * 0.25, fillCol, fill); outRgb += edge * uColor * uEdge * pulse; diff --git a/phaser-plus/src/engine/GameObject.ts b/phaser-plus/src/engine/GameObject.ts index 182b626a..36ec854c 100644 --- a/phaser-plus/src/engine/GameObject.ts +++ b/phaser-plus/src/engine/GameObject.ts @@ -2,8 +2,10 @@ import { generateId } from '@toolcase/base' import { Game, GameObjects, Math as M } from 'phaser' import EffectManager from '../effects/EffectManager' import type Scene from './Scene' +import type GameObjectComponent from './GameObjectComponent' type Child = GameObjects.GameObject +type ComponentClass = new () => T export default class GameObject extends GameObjects.Container { @@ -17,6 +19,10 @@ export default class GameObject extends GameObjects.Container { private _effects: EffectManager | null = null + private readonly _components: Map = new Map() + + private _created: boolean = false + constructor(scene: Scene, x: number, y: number) { super(scene as unknown as Phaser.Scene, x, y) this.scene = scene @@ -56,6 +62,39 @@ export default class GameObject extends GameObjects.Container { return out } + /** + * Attach a component to this object. The component is constructed with + * `new cls()` and its `owner` is set to this object. If the same class is + * added more than once the existing instance is returned unchanged. + * + * When called on a live object (after pool creation) `onCreate()` is + * invoked immediately on the new component. + */ + addComponent(cls: ComponentClass): T { + if (this._components.has(cls)) return this._components.get(cls) as T + const instance = new cls() + instance.owner = this as any + this._components.set(cls, instance) + if (this._created) instance.onCreate() + return instance + } + + /** Return the component instance for `cls`, or `null` if not attached. */ + getComponent(cls: ComponentClass): T | null { + return (this._components.get(cls) as T) ?? null + } + + /** + * Detach and destroy a component. Calls `onDestroy()` on the component + * before removing it. No-op if the component is not attached. + */ + removeComponent(cls: ComponentClass): void { + const instance = this._components.get(cls) + if (!instance) return + instance.onDestroy() + this._components.delete(cls) + } + onCreate(): void {} onAdd(_parent: GameObjects.GameObject): void {} @@ -67,6 +106,10 @@ export default class GameObject extends GameObjects.Container { onDestroy(): void {} protected preDestroy(): void { + this._effects?.clear() + this._effects = null + for (const c of this._components.values()) c.onDestroy() + this._components.clear() super.preDestroy() this.onDestroy() } @@ -101,9 +144,17 @@ export default class GameObject extends GameObjects.Container { return super.removeAll(destroyChild) as this } + /** @internal — called by GameObjectPool instead of onCreate() to ensure components are initialized. */ + doCreate(): void { + this._created = true + this.onCreate() + for (const c of this._components.values()) c.onCreate() + } + /** @internal */ doUpdate(time: number, delta: number): void { this.onUpdate(time, delta) + for (const c of this._components.values()) c.onUpdate(time, delta) for (const child of this.list) { if (child instanceof GameObject) child.doUpdate(time, delta) } diff --git a/phaser-plus/src/engine/GameObjectComponent.ts b/phaser-plus/src/engine/GameObjectComponent.ts new file mode 100644 index 00000000..567e57a3 --- /dev/null +++ b/phaser-plus/src/engine/GameObjectComponent.ts @@ -0,0 +1,23 @@ +import type GameObject from './GameObject' + +/** + * Base class for components attached to a `GameObject` via `addComponent()`. + * Subclass this to define reusable, composable behaviors. The owning object + * is available via `this.owner` after construction. + * + * Lifecycle (mirrors the owner's lifecycle): + * - `onCreate()` — called once when the owner is first obtained from the pool + * (or immediately when `addComponent()` is called on a live object). + * - `onUpdate(time, delta)` — called every frame as part of `doUpdate()`. + * - `onDestroy()` — called when `removeComponent()` is invoked, or when the + * owner is destroyed. + */ +export default abstract class GameObjectComponent { + owner!: GameObject + + onCreate(): void {} + + onUpdate(_time: number, _delta: number): void {} + + onDestroy(): void {} +} diff --git a/phaser-plus/src/engine/Scene.ts b/phaser-plus/src/engine/Scene.ts index 2553c6f6..fc009602 100644 --- a/phaser-plus/src/engine/Scene.ts +++ b/phaser-plus/src/engine/Scene.ts @@ -13,6 +13,12 @@ export default class Scene extends PhaserScene { engine!: Engine + /** + * Game-global service registry. Its lifetime matches the `Game` instance, + * not the Scene — services are shared across all scenes and persist through + * scene restarts. Call `services.disposeAll()` only when tearing down the + * entire game, not on scene shutdown. + */ services!: ServiceRegistry features!: FeatureRegistry @@ -25,6 +31,8 @@ export default class Scene extends PhaserScene { private layerSortFlag: boolean = false + private destroyed: boolean = false + /** @protected */ onInit(): void {} @@ -73,7 +81,9 @@ export default class Scene extends PhaserScene { protected beforeInit(): void {} init(): void { + this.destroyed = false this.engine = this.initializeEngine() + this.events.once(Scenes.Events.SHUTDOWN, this.doDestroy, this) this.events.once(Scenes.Events.DESTROY, this.doDestroy, this) this.logger = this.engine.getLogger(`scene=${this.scene.key}`) this.services = this.engine.services @@ -122,6 +132,10 @@ export default class Scene extends PhaserScene { } private doDestroy(): void { + if (this.destroyed) return + this.destroyed = true + this.events.off(Scenes.Events.SHUTDOWN, this.doDestroy, this) + this.events.off(Scenes.Events.DESTROY, this.doDestroy, this) this.features.off(LAYER_DEPTH_UPDATE, this.onLayerDepthUpdate, this) this.onDestroy() this.features.destroyAll() diff --git a/phaser-plus/src/engine/index.ts b/phaser-plus/src/engine/index.ts index 1e87068b..298f3ee4 100644 --- a/phaser-plus/src/engine/index.ts +++ b/phaser-plus/src/engine/index.ts @@ -1,11 +1,13 @@ import Engine from './Engine' import Scene from './Scene' import GameObject from './GameObject' +import GameObjectComponent from './GameObjectComponent' import * as Events from './Events' export { Engine, Scene, GameObject, + GameObjectComponent, Events } diff --git a/phaser-plus/src/features/Feature.ts b/phaser-plus/src/features/Feature.ts index decce230..7953d8ff 100644 --- a/phaser-plus/src/features/Feature.ts +++ b/phaser-plus/src/features/Feature.ts @@ -12,6 +12,8 @@ export default class Feature { protected readonly logger: Logger + private readonly _suppressedWarnings: Set = new Set() + constructor(scene: Scene, key: string) { this.scene = scene this.game = scene.game @@ -28,12 +30,25 @@ export default class Feature { onDestroy(): void {} /** - * Emit a feature event. Warns when no listener is registered. + * Mark one or more event names as fire-and-forget so that `emit` will not + * warn when no listener is registered for them. + */ + protected suppressWarning(...events: string[]): void { + for (const event of events) { + this._suppressedWarnings.add(event) + } + } + + /** + * Emit a feature event. Warns when no listener is registered unless the + * event was opted-out via `suppressWarning`. */ protected emit(event: string, ...messages: unknown[]): void { const events = this.scene.features as unknown as { listenerCount(name: string): number, emit(name: string, ...args: unknown[]): boolean } if (events.listenerCount(event) === 0) { - this.logger.warning(`event=(${event}) is not handled, payload sent:`, ...messages) + if (!this._suppressedWarnings.has(event)) { + this.logger.warning(`event=(${event}) is not handled, payload sent:`, ...messages) + } return } events.emit(event, ...messages) diff --git a/phaser-plus/src/features/FeatureRegistry.ts b/phaser-plus/src/features/FeatureRegistry.ts index 9f84be65..e1062602 100644 --- a/phaser-plus/src/features/FeatureRegistry.ts +++ b/phaser-plus/src/features/FeatureRegistry.ts @@ -30,6 +30,10 @@ export default class FeatureRegistry extends Broadcast { return this.features.size } + /** + * Features are constructed and `onCreate`-called eagerly in registration order. + * A feature may only `get()` dependencies that were registered before it. + */ register(key: string, featureClass: FeatureClass): T { if (this.features.has(key)) { throw new Error(`key ${key} is already in use by other feature`) @@ -65,7 +69,7 @@ export default class FeatureRegistry extends Broadcast { } destroyAll(): void { - for (const key of [...this.keys]) { + for (const key of [...this.keys].reverse()) { this.destroy(key) } } diff --git a/phaser-plus/src/features/HTMLFeature.ts b/phaser-plus/src/features/HTMLFeature.ts index e6cce66e..6ab04d86 100644 --- a/phaser-plus/src/features/HTMLFeature.ts +++ b/phaser-plus/src/features/HTMLFeature.ts @@ -3,14 +3,8 @@ import type Scene from '../engine/Scene' const STYLE_ID = '@phaser-plus/reef/dom' -let stylesInjected = false - function injectStylesOnce(): void { - if (stylesInjected) return - if (document.getElementById(STYLE_ID) !== null) { - stylesInjected = true - return - } + if (document.getElementById(STYLE_ID) !== null) return const style = document.createElement('style') style.id = STYLE_ID style.innerHTML = ` @@ -26,7 +20,6 @@ function injectStylesOnce(): void { user-select: none; }` document.head.append(style) - stylesInjected = true } export default class HTMLFeature extends Feature { diff --git a/phaser-plus/src/features/ReactFeature.ts b/phaser-plus/src/features/ReactFeature.ts index fdef89ad..34927b22 100644 --- a/phaser-plus/src/features/ReactFeature.ts +++ b/phaser-plus/src/features/ReactFeature.ts @@ -25,6 +25,8 @@ export default class ReactFeature extends HTMLFeature { private _loading: boolean = false + private _destroyed: boolean = false + constructor(scene: Scene, key: string) { super(scene, key) } @@ -44,7 +46,7 @@ export default class ReactFeature extends HTMLFeature { this._loading = true loadCreateRoot() .then((createRoot) => { - if (this._pending === null) { + if (this._destroyed || this._pending === null) { this._loading = false return } @@ -84,6 +86,7 @@ export default class ReactFeature extends HTMLFeature { } override preDestroy(): void { + this._destroyed = true this.unmount() super.preDestroy() } diff --git a/phaser-plus/src/features/ServiceRegistry.ts b/phaser-plus/src/features/ServiceRegistry.ts index 97c1f5a5..35bd0bfa 100644 --- a/phaser-plus/src/features/ServiceRegistry.ts +++ b/phaser-plus/src/features/ServiceRegistry.ts @@ -1,6 +1,10 @@ type ServiceClass = new () => T type ServiceFactory = () => T +export interface Disposable { + dispose(): void +} + export default class ServiceRegistry { private readonly services: Map, unknown> = new Map() @@ -46,10 +50,15 @@ export default class ServiceRegistry { dispose(serviceClass: ServiceClass): void { this.assertClass(serviceClass) + const instance = this.services.get(serviceClass) + ;(instance as Partial)?.dispose?.() this.services.delete(serviceClass) } disposeAll(): void { + for (const instance of this.services.values()) { + ;(instance as Partial)?.dispose?.() + } this.services.clear() this.factories.clear() } diff --git a/phaser-plus/src/features/SplitScreen.ts b/phaser-plus/src/features/SplitScreen.ts index 148feaa7..8337e944 100644 --- a/phaser-plus/src/features/SplitScreen.ts +++ b/phaser-plus/src/features/SplitScreen.ts @@ -47,6 +47,7 @@ export default class SplitScreen extends Feature { private mode: Mode = 'SINGLE' private angleHash: number = Number.NaN + private _originalMainCameraName: string = '' private splitEnter: number = 1.0 private splitExitSq: number = 0.85 * 0.85 @@ -83,6 +84,7 @@ export default class SplitScreen extends Feature { this.gfxA = new GameObjects.Graphics(this.scene) this.gfxB = new GameObjects.Graphics(this.scene) + this._originalMainCameraName = this.scene.cameras.main.name this.cameras.ui = this.scene.cameras.main.setName('ui') this.cameras.B = this.scene.cameras.add(0, 0, width, height, false, 'gameplay B') this.cameras.A = this.scene.cameras.add(0, 0, width, height, false, 'gameplay A') @@ -130,8 +132,12 @@ export default class SplitScreen extends Feature { const camsScene = this.scene.cameras this.detachMask(this.cameras.A, this.maskA) this.detachMask(this.cameras.B, this.maskB) + // Reverse cameras back to original order before removing A and B so main ends up at index 0. + camsScene.cameras.reverse() if (this.cameras.A !== null) camsScene.remove(this.cameras.A) if (this.cameras.B !== null) camsScene.remove(this.cameras.B) + // Restore the original name of cameras.main (was renamed to 'ui' in onCreate). + camsScene.main.setName(this._originalMainCameraName) this.gfxA?.destroy() this.gfxB?.destroy() this.maskA = null diff --git a/phaser-plus/src/features/index.ts b/phaser-plus/src/features/index.ts index 16b46bc4..3089d465 100644 --- a/phaser-plus/src/features/index.ts +++ b/phaser-plus/src/features/index.ts @@ -1,12 +1,14 @@ import Feature from './Feature' import FeatureRegistry from './FeatureRegistry' import ServiceRegistry from './ServiceRegistry' +import type { Disposable } from './ServiceRegistry' import Layer from './Layer' import ObjectLayer from './ObjectLayer' import HTMLFeature from './HTMLFeature' import ReactFeature from './ReactFeature' import SplitScreen from './SplitScreen' +export type { Disposable } export { Feature, FeatureRegistry, diff --git a/phaser-plus/src/flow/BehaviorTree.ts b/phaser-plus/src/flow/BehaviorTree.ts index fce7fd86..b8ae7128 100644 --- a/phaser-plus/src/flow/BehaviorTree.ts +++ b/phaser-plus/src/flow/BehaviorTree.ts @@ -113,23 +113,39 @@ export class Selector extends Composite { export class Parallel extends Composite { + // Latched result per child; null while still running. + // Resolved children are not re-ticked until reset() is called, making the + // composite deterministic with stateful children. + private latched: (Status | null)[] + constructor(children: BTNode[], private successThreshold: number = children.length) { super(children) + this.latched = new Array(children.length).fill(null) } tick(ctx: TickContext): Status { let successes = 0 let failures = 0 - for (const child of this.children) { - const status = child.tick(ctx) - if (status === SUCCESS) successes++ - else if (status === FAILURE) failures++ + for (let i = 0; i < this.children.length; i++) { + const status = this.latched[i] ?? this.children[i]!.tick(ctx) + if (status === SUCCESS) { + this.latched[i] = SUCCESS + successes++ + } else if (status === FAILURE) { + this.latched[i] = FAILURE + failures++ + } } if (successes >= this.successThreshold) return SUCCESS if (failures > this.children.length - this.successThreshold) return FAILURE return RUNNING } + override reset(ctx: TickContext): void { + super.reset(ctx) + this.latched.fill(null) + } + } abstract class Decorator extends BTNode { @@ -266,7 +282,7 @@ export default class BehaviorTreeProcessor extends FlowProcessor { if (entry.interval > 0) { entry.elapsed += dt if (entry.elapsed < entry.interval) continue - entry.elapsed = 0 + entry.elapsed -= entry.interval } const ctx: TickContext = { scene: this.scene, diff --git a/phaser-plus/src/flow/CollisionEventProcessor.ts b/phaser-plus/src/flow/CollisionEventProcessor.ts index 0114ee4e..537ff014 100644 --- a/phaser-plus/src/flow/CollisionEventProcessor.ts +++ b/phaser-plus/src/flow/CollisionEventProcessor.ts @@ -79,7 +79,13 @@ export default class CollisionEventProcessor extends FlowProcessor { return this } - private onCollisionEnter(event: Physics.Matter.Events.CollisionStartEvent, bodyA: MatterBody, bodyB: MatterBody): void { + private onCollisionEnter(event: Physics.Matter.Events.CollisionStartEvent): void { + for (const pair of event.pairs) { + this.dispatchEnter(event, pair.bodyA as MatterBody, pair.bodyB as MatterBody) + } + } + + private dispatchEnter(event: Physics.Matter.Events.CollisionStartEvent, bodyA: MatterBody, bodyB: MatterBody): void { const labelA = (bodyA as any)?.label const labelB = (bodyB as any)?.label if (labelA == null || labelB == null) return @@ -93,7 +99,13 @@ export default class CollisionEventProcessor extends FlowProcessor { collisionEvent.onEnter(bodyA, bodyB, event) } - private onCollisionExit(event: Physics.Matter.Events.CollisionEndEvent, bodyA: MatterBody, bodyB: MatterBody): void { + private onCollisionExit(event: Physics.Matter.Events.CollisionEndEvent): void { + for (const pair of event.pairs) { + this.dispatchExit(event, pair.bodyA as MatterBody, pair.bodyB as MatterBody) + } + } + + private dispatchExit(event: Physics.Matter.Events.CollisionEndEvent, bodyA: MatterBody, bodyB: MatterBody): void { const labelA = (bodyA as any)?.label const labelB = (bodyB as any)?.label if (labelA == null || labelB == null) return diff --git a/phaser-plus/src/flow/EventProcessor.ts b/phaser-plus/src/flow/EventProcessor.ts index 6f779509..dcfa3e0b 100644 --- a/phaser-plus/src/flow/EventProcessor.ts +++ b/phaser-plus/src/flow/EventProcessor.ts @@ -18,7 +18,13 @@ type EventClass, P> = new (scene: import('../engine/Scene').d export default class EventProcessor extends FlowProcessor { - private readonly timerDefPool = new ObjectPool(TimerDef) + private readonly timerDefPool = new ObjectPool(TimerDef, (def: TimerDef) => { + def.event = null + def.name = '' + def.time = 0 + def.payload = null + def.context = null + }) private queue: TimerDef[] = [] @@ -29,23 +35,28 @@ export default class EventProcessor extends FlowProcessor { } onUpdate(time: number, delta: number): void { - const indices: number[] = [] - for (let index = 0; index < this.queue.length; index++) { - const def = this.queue[index]! + // Snapshot the current queue so that re-triggers during firing land in + // this.queue and are deferred to the next frame instead of mutating the + // active iteration. + const snapshot = this.queue + this.queue = [] + + const remaining: TimerDef[] = [] + for (const def of snapshot) { def.time += delta / 1000 - if (def.time > 0) { + if (def.time >= 0) { if (def.name === TIMEOUT_FN_NAME) { (def.event as TimerCallback).call(def.context) } else { (def.event as Event).onFire(def.payload) } - indices.unshift(index) + this.timerDefPool.release(def) + } else { + remaining.push(def) } } - for (const index of indices) { - const [removed] = this.queue.splice(index, 1) - if (removed) this.timerDefPool.release(removed) - } + // Prepend still-waiting defs before any newly triggered (next-frame) defs. + this.queue = [...remaining, ...this.queue] } onDestroy(): void { @@ -95,7 +106,7 @@ export default class EventProcessor extends FlowProcessor { throw new Error(`event name=${eventName} is not registered`) } if (typeof delay !== 'number' || delay < 0) { - throw new Error(`delay must be a positive number`) + throw new Error(`delay must be a non-negative number`) } const def = this.timerDefPool.obtain() def.name = eventName @@ -117,7 +128,7 @@ export default class EventProcessor extends FlowProcessor { triggerFn(callbackFn: () => void, delay: number = 0, context: unknown = null): this { if (typeof delay !== 'number' || delay < 0) { - throw new Error(`delay must be a positive number`) + throw new Error(`delay must be a non-negative number`) } if (typeof callbackFn !== 'function') { throw new Error(`callbackFn must be a function`) diff --git a/phaser-plus/src/flow/FlowEngine.ts b/phaser-plus/src/flow/FlowEngine.ts index 8ef4e8fb..a111d03a 100644 --- a/phaser-plus/src/flow/FlowEngine.ts +++ b/phaser-plus/src/flow/FlowEngine.ts @@ -5,6 +5,9 @@ import EventProcessor from './EventProcessor' import TimeEventProcessor from './TimeEventProcessor' import CollisionEventProcessor from './CollisionEventProcessor' import JobProcessor from './JobProcessor' +import TweenProcessor from './TweenProcessor' +import Timeline from './Timeline' +import type { TweenOptions, TweenHandle, TimelineHandle } from './TweenProcessor' type ProcessorClass = new (scene: Scene, eventType: string) => T @@ -28,6 +31,8 @@ export default class FlowEngine { readonly physics: CollisionEventProcessor | null + readonly tweens: TweenProcessor + constructor(scene: Scene) { this.scene = scene this.logger = scene.engine.getLogger('flow') @@ -37,6 +42,15 @@ export default class FlowEngine { this.physics = (scene as any).matter !== undefined ? this.addProcessor('collision_event', CollisionEventProcessor) : null + this.tweens = this.addProcessor(TweenProcessor.EVENT_TYPE, TweenProcessor) + } + + tween(target: object, to: Record, opts: TweenOptions): TweenHandle { + return this.tweens.tween(target, to, opts) + } + + timeline(): Timeline { + return new Timeline(this.scene) } addProcessor(eventType: string, processorClass: ProcessorClass): T { diff --git a/phaser-plus/src/flow/JobProcessor.ts b/phaser-plus/src/flow/JobProcessor.ts index c87e135e..5a98d4a8 100644 --- a/phaser-plus/src/flow/JobProcessor.ts +++ b/phaser-plus/src/flow/JobProcessor.ts @@ -18,7 +18,10 @@ export default class JobProcessor extends FlowProcessor { } onUpdate(time: number): void { - let budget = this.maxJobsPerFrame + // Cap at the queue length at the start of the frame so re-queued + // long-running jobs cannot consume a second budget slot in the same + // frame. Each unique job is advanced at most once per frame. + let budget = Math.min(this.maxJobsPerFrame, this.queue.length) while (budget-- > 0 && this.queue.length > 0) { const job = this.queue.shift()! let signal: boolean | void diff --git a/phaser-plus/src/flow/Parallel.ts b/phaser-plus/src/flow/Parallel.ts index e20ba353..01a1ea80 100644 --- a/phaser-plus/src/flow/Parallel.ts +++ b/phaser-plus/src/flow/Parallel.ts @@ -17,6 +17,16 @@ export default class Parallel { let cancelled = false const total = tasks.length + if (total === 0) { + onAll?.() + return { + cancel() {}, + get running() { return 0 }, + get remaining() { return 0 }, + get done() { return true } + } + } + const startNext = (): void => { if (cancelled) return while (running < limit && index < total) { diff --git a/phaser-plus/src/flow/ReplayRecorder.ts b/phaser-plus/src/flow/ReplayRecorder.ts index 3f90fbbc..16eb0be8 100644 --- a/phaser-plus/src/flow/ReplayRecorder.ts +++ b/phaser-plus/src/flow/ReplayRecorder.ts @@ -20,6 +20,13 @@ export const REPLAY_END = 'replay.end' export default class ReplayRecorder extends Feature { + /** + * Maximum number of frames the recording buffer will hold. + * When the limit is reached, recording stops automatically and a console + * warning is emitted. Set to 0 (default) for an unbounded buffer. + */ + maxFrames: number = 0 + private mode: ReplayMode = 'idle' private currentTick: number = 0 @@ -76,6 +83,18 @@ export default class ReplayRecorder extends Feature { return this } + /** + * Jump playback to the given frame index. No-op when not playing. + * Useful for scrubbing from a `TimelinePanel` bound via `bindReplay`. + */ + seekTo(frameIndex: number): this { + if (this.mode !== 'playing' || !this.currentSession) return this + const total = this.currentSession.frames.length + this.playCursor = Math.max(0, Math.min(Math.floor(frameIndex), total)) + this.currentTick = this.playCursor + return this + } + readInput(key: string): T | undefined { if (this.mode === 'playing') { const frame = this.currentSession?.frames[this.playCursor] @@ -107,6 +126,13 @@ export default class ReplayRecorder extends Feature { private advance(): void { if (this.mode === 'recording') { + if (this.maxFrames > 0 && this.currentSession!.frames.length >= this.maxFrames) { + console.warn( + `[ReplayRecorder] buffer cap reached (${this.maxFrames} frames); recording stopped` + ) + this.mode = 'idle' + return + } this.currentSession!.frames.push({ tick: this.currentTick, inputs: { ...this.currentInputs } diff --git a/phaser-plus/src/flow/StateMachine.ts b/phaser-plus/src/flow/StateMachine.ts index 3f191db4..82f0b1d1 100644 --- a/phaser-plus/src/flow/StateMachine.ts +++ b/phaser-plus/src/flow/StateMachine.ts @@ -60,6 +60,17 @@ export default class StateMachine extends Feature { return this } + /** + * Register a transition rule. Priority is first-registered-wins: transitions + * added earlier take precedence over later ones when multiple rules match the + * same (from, signal) pair. Keep high-priority transitions at the top of your + * setup sequence to make priority explicit. + * + * Guardless automatic transitions (on=null) are evaluated every frame from + * onUpdate. Avoid unconditional auto-transitions (guard=null) between two + * states unless you intend them to fire exactly once — the self-transition + * short-circuit prevents infinite ping-pong, but a mutual pair still cycles. + */ addTransition(from: string | null, to: string, on: string | null = null, guard: Guard | null = null): this { if (!this.states.has(to)) { throw new Error(`transition target=${to} not defined as state`) @@ -139,6 +150,10 @@ export default class StateMachine extends Feature { private transition(target: string): boolean { const next = this.states.get(target) if (!next) return false + // Self-transitions are silently suppressed: onExit/onEnter do not fire + // and no events are emitted. Use an explicit guard or a dedicated signal + // if re-entry behaviour is required. + if (target === this.currentState) return false const previous = this.currentState if (previous !== null) { const prev = this.states.get(previous) diff --git a/phaser-plus/src/flow/TimeEventProcessor.ts b/phaser-plus/src/flow/TimeEventProcessor.ts index 2ad55de4..abd4169f 100644 --- a/phaser-plus/src/flow/TimeEventProcessor.ts +++ b/phaser-plus/src/flow/TimeEventProcessor.ts @@ -21,7 +21,7 @@ export default class TimeEventProcessor extends FlowProcessor { for (const timer of this.timers.values()) { if (timer.paused) continue timer.current += dt - while (timer.current > timer.interval) { + while (timer.current >= timer.interval) { timer.current -= timer.interval timer.counter++ timer.event.onFire(timer.counter) diff --git a/phaser-plus/src/flow/Timeline.ts b/phaser-plus/src/flow/Timeline.ts new file mode 100644 index 00000000..997bc4b9 --- /dev/null +++ b/phaser-plus/src/flow/Timeline.ts @@ -0,0 +1,90 @@ +import type Scene from '../engine/Scene' +import type { TweenOptions, TimelineHandle, TimelineStep } from './TweenProcessor' + +export interface ParallelBuilder { + to(target: object, to: Record, opts: TweenOptions): ParallelBuilder + call(fn: () => void): ParallelBuilder +} + +export default class Timeline { + + private _steps: TimelineStep[] = [] + private _cursor: number = 0 + private _scene: Scene + + constructor(scene: Scene) { + this._scene = scene + } + + to(target: object, to: Record, opts: TweenOptions): this { + const start = this._cursor + (opts.delay ?? 0) + this._steps.push({ kind: 'tween', target, to, opts: { ...opts, delay: 0 }, startAt: start }) + this._cursor = start + opts.ms + return this + } + + parallel(callback: (sub: ParallelBuilder) => void): this { + const base = this._cursor + let max = 0 + const sub: ParallelBuilder = { + to: (target: object, to: Record, opts: TweenOptions): ParallelBuilder => { + const dur = (opts.delay ?? 0) + opts.ms + if (dur > max) max = dur + this._steps.push({ + kind: 'tween', + target, + to, + opts: { ...opts, delay: 0 }, + startAt: base + (opts.delay ?? 0) + }) + return sub + }, + call: (fn: () => void): ParallelBuilder => { + this._steps.push({ kind: 'call', startAt: base, fn }) + return sub + } + } + callback(sub) + this._cursor = base + max + return this + } + + stagger(targets: object[], to: Record, opts: TweenOptions, staggerMs: number): this { + const base = this._cursor + for (let i = 0; i < targets.length; i++) { + this._steps.push({ + kind: 'tween', + target: targets[i], + to, + opts: { ...opts, delay: 0 }, + startAt: base + i * staggerMs + }) + } + if (targets.length > 0) { + this._cursor = base + (targets.length - 1) * staggerMs + opts.ms + } + return this + } + + wait(ms: number): this { + this._steps.push({ kind: 'wait', startAt: this._cursor, duration: ms }) + this._cursor += ms + return this + } + + call(fn: () => void): this { + this._steps.push({ kind: 'call', startAt: this._cursor, fn }) + return this + } + + play(onComplete?: () => void): TimelineHandle { + const processor = (this._scene.flow as any).tweens + return (processor as { _addTimeline(s: readonly TimelineStep[], t: number, c: (() => void) | null): TimelineHandle }) + ._addTimeline(this._steps, this._cursor, onComplete ?? null) + } + + get totalDuration(): number { + return this._cursor + } + +} diff --git a/phaser-plus/src/flow/Tween.ts b/phaser-plus/src/flow/Tween.ts new file mode 100644 index 00000000..a1e96743 --- /dev/null +++ b/phaser-plus/src/flow/Tween.ts @@ -0,0 +1,15 @@ +import type Scene from '../engine/Scene' +import type { TweenOptions, TweenHandle } from './TweenProcessor' + +export default class Tween { + + static to(scene: Scene, target: object, to: Record, opts: TweenOptions): TweenHandle { + const processor = (scene.flow as any).tweens + return processor.tween(target, to, opts) + } + + static cancel(handle: TweenHandle): void { + handle.cancel() + } + +} diff --git a/phaser-plus/src/flow/TweenProcessor.ts b/phaser-plus/src/flow/TweenProcessor.ts new file mode 100644 index 00000000..c49bf350 --- /dev/null +++ b/phaser-plus/src/flow/TweenProcessor.ts @@ -0,0 +1,305 @@ +import FlowProcessor from './FlowProcessor' +import type Scene from '../engine/Scene' +import { resolveEase } from './easing' +import type { EaseFn } from './easing' + +export interface TweenOptions { + ms: number + ease?: EaseFn | string + delay?: number + loop?: boolean | number + yoyo?: boolean + onComplete?: () => void + onUpdate?: (progress: number, target: object) => void +} + +export interface TweenHandle { + cancel(): void + pause(): void + resume(): void + readonly completed: boolean + readonly cancelled: boolean + readonly paused: boolean + readonly progress: number +} + +export interface TimelineHandle { + cancel(): void + pause(): void + resume(): void + seek(ms: number): void + restart(): void + readonly completed: boolean + readonly cancelled: boolean + readonly paused: boolean + readonly elapsed: number + readonly total: number + readonly progress: number +} + +export type TimelineStep = + | { readonly kind: 'tween'; target: object; to: Record; opts: TweenOptions; startAt: number } + | { readonly kind: 'wait'; startAt: number; duration: number } + | { readonly kind: 'call'; startAt: number; fn: () => void } + +interface ActiveTween { + target: Record + starts: Record + ends: Record + elapsed: number + delay: number + duration: number + ease: EaseFn + loop: number + yoyo: boolean + forward: boolean + paused: boolean + completed: boolean + cancelled: boolean + onComplete: (() => void) | null + onUpdate: ((t: number, target: object) => void) | null +} + +interface ActiveTimeline { + steps: readonly TimelineStep[] + initialValues: Map> + fired: Set + elapsed: number + total: number + paused: boolean + completed: boolean + cancelled: boolean + onComplete: (() => void) | null +} + +export default class TweenProcessor extends FlowProcessor { + + static readonly EVENT_TYPE = 'tween' + + onLog: ((time: number, type: string, name: string) => void) | null = null + + private _tweens: ActiveTween[] = [] + private _timelines: ActiveTimeline[] = [] + + constructor(scene: Scene, eventType: string) { + super(scene, eventType) + } + + tween(target: object, to: Record, opts: TweenOptions): TweenHandle { + const t = target as Record + const starts: Record = {} + for (const k of Object.keys(to)) { + starts[k] = typeof t[k] === 'number' ? t[k] : 0 + } + const loop = opts.loop === true ? -1 : (typeof opts.loop === 'number' ? opts.loop : 0) + const state: ActiveTween = { + target: t, + starts, + ends: { ...to }, + elapsed: 0, + delay: opts.delay ?? 0, + duration: opts.ms, + ease: resolveEase(opts.ease), + loop, + yoyo: opts.yoyo ?? false, + forward: true, + paused: false, + completed: false, + cancelled: false, + onComplete: opts.onComplete ?? null, + onUpdate: opts.onUpdate ?? null + } + this._tweens.push(state) + this._log('tween:start', Object.keys(to).join(',')) + return { + cancel: () => { state.cancelled = true }, + pause: () => { state.paused = true }, + resume: () => { state.paused = false }, + get completed() { return state.completed }, + get cancelled() { return state.cancelled }, + get paused() { return state.paused }, + get progress() { + if (state.duration <= 0) return 1 + return Math.min(1, Math.max(0, state.elapsed / state.duration)) + } + } + } + + _addTimeline(steps: readonly TimelineStep[], total: number, onComplete: (() => void) | null): TimelineHandle { + const initialValues = new Map>() + for (const step of steps) { + if (step.kind === 'tween') { + const tgt = step.target as Record + const iv: Record = {} + for (const k of Object.keys(step.to)) { + iv[k] = typeof tgt[k] === 'number' ? tgt[k] : 0 + } + initialValues.set(step, iv) + } + } + + const inst: ActiveTimeline = { + steps, + initialValues, + fired: new Set(), + elapsed: 0, + total, + paused: false, + completed: false, + cancelled: false, + onComplete + } + + this._timelines.push(inst) + this._log('timeline:start', `${steps.length}steps`) + + const self = this + const handle: TimelineHandle = { + cancel() { inst.cancelled = true }, + pause() { inst.paused = true }, + resume() { inst.paused = false }, + seek(ms: number) { self._seekTimeline(inst, ms) }, + restart() { + inst.cancelled = false + inst.completed = false + inst.fired.clear() + inst.elapsed = 0 + if (!self._timelines.includes(inst)) { + self._timelines.push(inst) + } + self._log('timeline:start', `${inst.steps.length}steps`) + }, + get completed() { return inst.completed }, + get cancelled() { return inst.cancelled }, + get paused() { return inst.paused }, + get elapsed() { return inst.elapsed }, + get total() { return inst.total }, + get progress() { return inst.total > 0 ? Math.min(1, inst.elapsed / inst.total) : 1 } + } + + return handle + } + + cancelAll(): void { + for (const t of this._tweens) t.cancelled = true + for (const tl of this._timelines) tl.cancelled = true + } + + pauseAll(): void { + for (const t of this._tweens) t.paused = true + for (const tl of this._timelines) tl.paused = true + } + + resumeAll(): void { + for (const t of this._tweens) t.paused = false + for (const tl of this._timelines) tl.paused = false + } + + override onUpdate(_time: number, delta: number): void { + this._tickTweens(delta) + this._tickTimelines(delta) + } + + override onDestroy(): void { + this._tweens.length = 0 + this._timelines.length = 0 + this.onLog = null + } + + private _tickTweens(delta: number): void { + for (const s of this._tweens) { + if (s.cancelled || s.completed || s.paused) continue + if (s.delay > 0) { + s.delay -= delta + if (s.delay > 0) continue + s.elapsed = -s.delay + s.delay = 0 + } else { + s.elapsed += delta + } + this._advanceTween(s) + } + this._tweens = this._tweens.filter(s => !s.cancelled && !s.completed) + } + + private _advanceTween(s: ActiveTween): void { + const rawT = s.duration > 0 ? Math.min(1, s.elapsed / s.duration) : 1 + const dirT = s.forward ? rawT : 1 - rawT + const progress = s.ease(dirT) + for (const k of Object.keys(s.starts)) { + s.target[k] = s.starts[k] + (s.ends[k] - s.starts[k]) * progress + } + s.onUpdate?.(dirT, s.target as object) + if (rawT >= 1) { + if (s.loop !== 0) { + if (s.loop > 0) s.loop-- + s.elapsed = 0 + if (s.yoyo) s.forward = !s.forward + } else { + const finalDir = s.forward ? 1 : 0 + const finalProgress = s.ease(finalDir) + for (const k of Object.keys(s.starts)) { + s.target[k] = s.starts[k] + (s.ends[k] - s.starts[k]) * finalProgress + } + s.completed = true + s.onComplete?.() + this._log('tween:complete', Object.keys(s.starts).join(',')) + } + } + } + + private _tickTimelines(delta: number): void { + for (const inst of this._timelines) { + if (inst.cancelled || inst.completed || inst.paused) continue + inst.elapsed += delta + if (inst.elapsed >= inst.total) { + inst.elapsed = inst.total + this._applyTimeline(inst, inst.elapsed) + inst.completed = true + inst.onComplete?.() + this._log('timeline:complete', `${inst.steps.length}steps`) + } else { + this._applyTimeline(inst, inst.elapsed) + } + } + this._timelines = this._timelines.filter(inst => !inst.cancelled && !inst.completed) + } + + _seekTimeline(inst: ActiveTimeline, ms: number): void { + const clamped = Math.min(inst.total, Math.max(0, ms)) + inst.elapsed = clamped + if (clamped < inst.total) inst.completed = false + inst.fired.clear() + this._applyTimeline(inst, clamped) + } + + _applyTimeline(inst: ActiveTimeline, elapsed: number): void { + for (const step of inst.steps) { + if (step.kind === 'tween') { + if (elapsed < step.startAt) continue + const iv = inst.initialValues.get(step) + if (iv === undefined) continue + const duration = step.opts.ms + const t = duration > 0 ? Math.min(1, (elapsed - step.startAt) / duration) : 1 + const ease = resolveEase(step.opts.ease) + const progress = ease(t) + const tgt = step.target as Record + for (const k of Object.keys(step.to)) { + tgt[k] = iv[k] + (step.to[k] - iv[k]) * progress + } + } else if (step.kind === 'call') { + if (elapsed >= step.startAt && !inst.fired.has(step)) { + inst.fired.add(step) + step.fn() + } + } + } + } + + private _log(type: string, name: string): void { + if (this.onLog !== null) { + this.onLog((this.scene.game as any).loop?.time ?? 0, type, name) + } + } + +} diff --git a/phaser-plus/src/flow/debounce.ts b/phaser-plus/src/flow/debounce.ts index 5177db9d..b46da2db 100644 --- a/phaser-plus/src/flow/debounce.ts +++ b/phaser-plus/src/flow/debounce.ts @@ -2,19 +2,27 @@ type AnyFn = (...args: any[]) => void export interface DebounceOptions { leading?: boolean + trailing?: boolean } export default function debounce(fn: T, ms: number, options: DebounceOptions = {}): T { - const { leading = false } = options + const { leading = false, trailing = true } = options let timer: ReturnType | null = null + let lastArgs: Parameters | null = null + let calledThisWindow = false return ((...args: Parameters): void => { const callNow = leading && timer === null + lastArgs = args if (timer !== null) clearTimeout(timer) timer = setTimeout(() => { timer = null - if (!leading) fn(...(args as any[])) + if (trailing && (!leading || calledThisWindow) && lastArgs !== null) { + fn(...(lastArgs as any[])) + } + lastArgs = null + calledThisWindow = false }, ms) - if (callNow) fn(...(args as any[])) + if (callNow) { fn(...(args as any[])) } else { calledThisWindow = true } }) as T } diff --git a/phaser-plus/src/flow/easing.ts b/phaser-plus/src/flow/easing.ts new file mode 100644 index 00000000..97b092dd --- /dev/null +++ b/phaser-plus/src/flow/easing.ts @@ -0,0 +1,43 @@ +export type EaseFn = (t: number) => number + +const c1 = 1.70158 +const c2 = c1 * 1.525 + +function bounceOut(t: number): number { + if (t < 1 / 2.75) return 7.5625 * t * t + if (t < 2 / 2.75) { const n = t - 1.5 / 2.75; return 7.5625 * n * n + 0.75 } + if (t < 2.5 / 2.75) { const n = t - 2.25 / 2.75; return 7.5625 * n * n + 0.9375 } + const n = t - 2.625 / 2.75; return 7.5625 * n * n + 0.984375 +} + +export const EASE: Record = { + linear: t => t, + easeIn: t => t * t, + easeOut: t => t * (2 - t), + easeInOut: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t, + easeInCubic: t => t * t * t, + easeOutCubic: t => { const n = t - 1; return n * n * n + 1 }, + easeInOutCubic: t => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, + easeInBack: t => (c1 + 1) * t * t * t - c1 * t * t, + easeOutBack: t => { const n = t - 1; return 1 + (c1 + 1) * n * n * n + c1 * n * n }, + easeInOutBack: t => t < 0.5 + ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 + : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (2 * t - 2) + c2) + 2) / 2, + bounce: bounceOut, + bounceIn: t => 1 - bounceOut(1 - t), + bounceOut, + elastic: t => { + if (t === 0 || t === 1) return t + return -Math.pow(2, 10 * (t - 1)) * Math.sin((t - 1.1) * 5 * Math.PI) + } +} + +export function resolveEase(ease: EaseFn | string | undefined): EaseFn { + if (typeof ease === 'function') return ease + if (typeof ease === 'string') { + const fn = EASE[ease] + if (fn === undefined) throw new Error(`Unknown easing "${ease}". Known: ${Object.keys(EASE).join(', ')}`) + return fn + } + return EASE.linear +} diff --git a/phaser-plus/src/flow/index.ts b/phaser-plus/src/flow/index.ts index 9048d5a6..1d2d209c 100644 --- a/phaser-plus/src/flow/index.ts +++ b/phaser-plus/src/flow/index.ts @@ -38,6 +38,13 @@ import throttle from './throttle' import type { ThrottleOptions } from './throttle' import debounce from './debounce' import type { DebounceOptions } from './debounce' +import TweenProcessor from './TweenProcessor' +import type { TweenOptions, TweenHandle, TimelineHandle, TimelineStep } from './TweenProcessor' +import Timeline from './Timeline' +import type { ParallelBuilder } from './Timeline' +import Tween from './Tween' +import { EASE, resolveEase } from './easing' +import type { EaseFn } from './easing' export { Event, @@ -69,7 +76,12 @@ export { Timer, ParallelRunner, throttle, - debounce + debounce, + TweenProcessor, + Timeline, + Tween, + EASE, + resolveEase } export type { @@ -87,5 +99,11 @@ export type { ParallelTask, ParallelHandle, ThrottleOptions, - DebounceOptions + DebounceOptions, + TweenOptions, + TweenHandle, + TimelineHandle, + TimelineStep, + ParallelBuilder, + EaseFn } diff --git a/phaser-plus/src/flow/throttle.ts b/phaser-plus/src/flow/throttle.ts index 9360edce..9b64a795 100644 --- a/phaser-plus/src/flow/throttle.ts +++ b/phaser-plus/src/flow/throttle.ts @@ -18,10 +18,8 @@ export default function throttle(fn: T, ms: number, options: Th return ((...args: Parameters): void => { const now = Date.now() + if (last === 0 && !leading) last = now const elapsed = now - last - if (last === 0 && !leading) { - last = now - } if (elapsed >= ms) { if (timer !== null) { clearTimeout(timer) @@ -40,7 +38,7 @@ export default function throttle(fn: T, ms: number, options: Th lastArgs = null invoke(next) } - }, ms - elapsed) + }, Math.max(0, ms - elapsed)) } }) as T } diff --git a/phaser-plus/src/index.ts b/phaser-plus/src/index.ts index 41db0ba3..324bab8e 100644 --- a/phaser-plus/src/index.ts +++ b/phaser-plus/src/index.ts @@ -3,11 +3,13 @@ import { Level as LogLevel } from '@toolcase/logging' import Engine from './engine/Engine' import Scene from './engine/Scene' import GameObject from './engine/GameObject' +import GameObjectComponent from './engine/GameObjectComponent' import * as Events from './engine/Events' import Feature from './features/Feature' import FeatureRegistry from './features/FeatureRegistry' import ServiceRegistry from './features/ServiceRegistry' +import type { Disposable } from './features/ServiceRegistry' import Layer from './features/Layer' import ObjectLayer from './features/ObjectLayer' import HTMLFeature from './features/HTMLFeature' @@ -16,6 +18,7 @@ import SplitScreen from './features/SplitScreen' import GameObjectPool from './pool/GameObjectPool' import Matrix2 from './math/Matrix2' +import { SpatialHash, Quadtree, Vec2, Easing, Random, AABB, Transform } from './structs' import Event from './flow/Event' import TimeEvent from './flow/TimeEvent' @@ -40,6 +43,10 @@ import Timer from './flow/Timer' import ParallelRunner from './flow/Parallel' import throttle from './flow/throttle' import debounce from './flow/debounce' +import TweenProcessor from './flow/TweenProcessor' +import Timeline from './flow/Timeline' +import Tween from './flow/Tween' +import { EASE, resolveEase } from './flow/easing' const Flow = { Event, @@ -51,6 +58,11 @@ const Flow = { BehaviorTreeProcessor, ReplayRecorder, Timer, + Tween, + Timeline, + TweenProcessor, + EASE, + resolveEase, Parallel: ParallelRunner, throttle, debounce, @@ -69,9 +81,18 @@ const Flow = { } const Structs = { - Matrix2 + Matrix2, + SpatialHash, + Quadtree, + Vec2, + AABB, + Transform, + Easing, + Random, } +export type { Disposable } +export type { SpatialPoint, SpatialRect, EasingFn, Rect } from './structs' export { Events, Flow, @@ -80,6 +101,7 @@ export { Engine, Scene, GameObject, + GameObjectComponent, Feature, FeatureRegistry, ServiceRegistry, @@ -96,6 +118,12 @@ export * from './debugger' export * from './perspective2d' export * from './effects' export * from './ai' +export * from './tilemap' export * from './cinema' export * from './input' export * from './flow' +export * from './audio' +export * from './persistence' +export * from './assets' +export * from './particles' +export * from './net' diff --git a/phaser-plus/src/input/GamepadFeature.ts b/phaser-plus/src/input/GamepadFeature.ts index 2cecbdec..52925c0f 100644 --- a/phaser-plus/src/input/GamepadFeature.ts +++ b/phaser-plus/src/input/GamepadFeature.ts @@ -120,7 +120,7 @@ export default class GamepadFeature extends Feature { if (!state) return { x: 0, y: 0, rx: 0, ry: 0 } const dz = state.deadZone const ax = state.pad.axes - const apply = (v: number) => Math.abs(v) < dz ? 0 : v + const apply = (v: number) => Math.abs(v) < dz ? 0 : Math.sign(v) * (Math.abs(v) - dz) / (1 - dz) return { x: apply(ax[0]?.getValue() ?? 0), y: apply(ax[1]?.getValue() ?? 0), diff --git a/phaser-plus/src/input/InputFeature.ts b/phaser-plus/src/input/InputFeature.ts index 50d44a53..e812721f 100644 --- a/phaser-plus/src/input/InputFeature.ts +++ b/phaser-plus/src/input/InputFeature.ts @@ -176,6 +176,8 @@ export default class InputFeature extends Feature { override onDestroy(): void { this.actions.clear() this.virtualState.clear() + const keyboard = this.scene.input.keyboard + for (const code of this.keys.keys()) keyboard?.removeKey(code, true, true) this.keys.clear() } diff --git a/phaser-plus/src/input/VirtualJoystick.ts b/phaser-plus/src/input/VirtualJoystick.ts index 1a95ec97..6851ad19 100644 --- a/phaser-plus/src/input/VirtualJoystick.ts +++ b/phaser-plus/src/input/VirtualJoystick.ts @@ -78,6 +78,8 @@ function injectOnce(): void { interface ButtonHandle { config: Required el: HTMLDivElement + press: (event: PointerEvent) => void + release: (event: PointerEvent) => void } export default class VirtualJoystick extends HTMLFeature { @@ -145,22 +147,27 @@ export default class VirtualJoystick extends HTMLFeature { this.input?.setVirtual(merged.id, true) el.setPointerCapture(event.pointerId) } - const release = () => { + const release = (event: PointerEvent) => { el.classList.remove('is-pressed') this.input?.setVirtual(merged.id, false) + el.releasePointerCapture(event.pointerId) } el.addEventListener('pointerdown', press) el.addEventListener('pointerup', release) el.addEventListener('pointercancel', release) el.addEventListener('pointerleave', release) this.node.append(el) - this.buttons.set(merged.id, { config: merged, el }) + this.buttons.set(merged.id, { config: merged, el, press, release }) return this } removeButton(id: string): this { const handle = this.buttons.get(id) if (!handle) return this + handle.el.removeEventListener('pointerdown', handle.press) + handle.el.removeEventListener('pointerup', handle.release) + handle.el.removeEventListener('pointercancel', handle.release) + handle.el.removeEventListener('pointerleave', handle.release) handle.el.remove() this.buttons.delete(id) return this @@ -268,9 +275,21 @@ export default class VirtualJoystick extends HTMLFeature { } override onDestroy(): void { - for (const handle of this.buttons.values()) handle.el.remove() + for (const handle of this.buttons.values()) { + handle.el.removeEventListener('pointerdown', handle.press) + handle.el.removeEventListener('pointerup', handle.release) + handle.el.removeEventListener('pointercancel', handle.release) + handle.el.removeEventListener('pointerleave', handle.release) + handle.el.remove() + } this.buttons.clear() - this.joystickEl?.remove() + if (this.joystickEl) { + this.joystickEl.removeEventListener('pointerdown', this.onPointerDown) + this.joystickEl.removeEventListener('pointermove', this.onPointerMove) + this.joystickEl.removeEventListener('pointerup', this.onPointerUp) + this.joystickEl.removeEventListener('pointercancel', this.onPointerUp) + this.joystickEl.remove() + } this.joystickEl = null this.thumbEl = null } diff --git a/phaser-plus/src/math/Matrix2.ts b/phaser-plus/src/math/Matrix2.ts index 55b8b4ea..9af27a90 100644 --- a/phaser-plus/src/math/Matrix2.ts +++ b/phaser-plus/src/math/Matrix2.ts @@ -14,7 +14,7 @@ export default class Matrix2 extends Float32Array { determinant: number = 0 - constructor(v00 = 1, v01 = 1, v10 = 1, v11 = 1, inverse: Matrix2 | null = null) { + constructor(v00 = 1, v01 = 0, v10 = 0, v11 = 1, inverse: Matrix2 | null = null) { super(4) this.setValues(v00, v01, v10, v11, inverse) } @@ -24,25 +24,40 @@ export default class Matrix2 extends Float32Array { get v10(): number { return this[2] as number } get v11(): number { return this[3] as number } - translate(x: number, y: number, out: M.Vector2 = new M.Vector2()): M.Vector2 { + /** + * Apply the 2×2 linear transform to point (x, y) and write the result into `out`. + * This is a pure matrix–vector multiply (M·[x,y]ᵀ) — there is no translation + * component; a 2×2 matrix cannot encode one (that requires an affine / 3×3 form). + */ + transformPoint(x: number, y: number, out: M.Vector2 = new M.Vector2()): M.Vector2 { const p = Matrix2.PRECISION out.x = Math.round((x * this.v00 + y * this.v01) * p) / p out.y = Math.round((x * this.v10 + y * this.v11) * p) / p return out } + /** @deprecated Use `transformPoint` instead — this method does not translate. */ + translate(x: number, y: number, out?: M.Vector2): M.Vector2 { + return this.transformPoint(x, y, out) + } + setValues(v00: number, v01: number, v10: number, v11: number, inverse: Matrix2 | null = null): void { this.set([v00, v01, v10, v11], 0) this.adjoint.set([v11, -v01, -v10, v00], 0) - this.determinant = 1 / (v00 * v11 - v01 * v10) + this.determinant = v00 * v11 - v01 * v10 if (inverse !== null) { this.inverse = inverse return } - const d = this.determinant - this.inverse = new Matrix2(v11 * d, -v01 * d, -v10 * d, v00 * d, this) + if (this.determinant === 0 || !isFinite(this.determinant)) { + this.inverse = new Matrix2(NaN, NaN, NaN, NaN, this) + return + } + + const invDet = 1 / this.determinant + this.inverse = new Matrix2(v11 * invDet, -v01 * invDet, -v10 * invDet, v00 * invDet, this) } static createISO(size: number = 64): Matrix2 { diff --git a/phaser-plus/src/net/NetFeature.ts b/phaser-plus/src/net/NetFeature.ts new file mode 100644 index 00000000..731a0f00 --- /dev/null +++ b/phaser-plus/src/net/NetFeature.ts @@ -0,0 +1,396 @@ +import Feature from '../features/Feature' +import type Scene from '../engine/Scene' +import type Debugger from '../debugger/Debugger' +import type NetPanel from '../debugger/panels/NetPanel' +import type { Transport } from './Transport' + +// ─── public event constants ──────────────────────────────────────────────── + +export const NET_CONNECTED = 'net.connected' +export const NET_DISCONNECTED = 'net.disconnected' +export const NET_RTT_UPDATE = 'net.rtt' +export const NET_ENTITY_STATE = 'net.entity_state' + +// ─── internal wire-format constants ──────────────────────────────────────── + +const MSG_PING = 0 +const MSG_PONG = 1 +const MSG_SYNC = 2 +const MSG_ACK = 3 + +// ─── internal state types ────────────────────────────────────────────────── + +interface RemoteState { + state: Record + ts: number +} + +interface RemoteEntity { + buffer: RemoteState[] +} + +interface LocalEntity { + lastSentState: Record + predictedState: Record | null +} + +// ─── shared codec instances ──────────────────────────────────────────────── + +const _enc = new TextEncoder() +const _dec = new TextDecoder() + +/** + * Scene-lifetime `Feature` that drives a `Transport` and provides: + * + * - Ping / pong RTT measurement, reported into `NetPanel`. + * - Entity state sync with snapshot + delta encoding (`syncEntity`). + * - Interpolation buffer for remote entity positions (`interpolateEntity`). + * - Optional client-side prediction (`predict` / `getPredicted`). + * - `bindDebugger(dbg)` to wire RTT / loss / sent / recv into `NetPanel`. + * + * Wire format: `[1 byte type][JSON UTF-8 payload]`. + * Swap for binary framing by replacing `_sendMessage` / `_onMessage` with + * a `@toolcase/serializer`-based codec — the rest of the class is agnostic. + * + * ### Minimal usage + * ```ts + * import { NetFeature, LoopbackTransport, NetPanel } from '@toolcase/phaser-plus' + * + * const [clientT, serverT] = LoopbackTransport.pair(50) + * + * const netA = this.features.register('net-client', NetFeature) + * netA.setTransport(clientT) + * + * const netB = this.features.register('net-server', NetFeature) + * netB.setTransport(serverT) + * + * netA.connect() // connects both sides + * netA.syncEntity('player', { x, y }) // send state + * const state = netB.getEntityState('player') // read received state + * ``` + */ +export default class NetFeature extends Feature { + + /** How often to send a ping (ms). */ + pingIntervalMs = 500 + + /** Unacknowledged ping older than this is counted as lost. */ + pingTimeoutMs = 5000 + + /** How far behind real-time to render remote entities (ms) for smooth interpolation. */ + interpolationDelay = 100 + + /** Maximum number of buffered received states per remote entity. */ + maxStateBuffer = 10 + + private _transport: Transport | null = null + private _panel: NetPanel | null = null + + private _pingSeq = 0 + private _pendingPings = new Map() // seq → sendTime + private _pingTimer = 0 + private _currentRTT = 0 + private _lostPings = 0 + private _totalPings = 0 + + private _remoteEntities = new Map() + private _localEntities = new Map() + private _netSeq = 0 + + constructor(scene: Scene, key: string) { + super(scene, key) + } + + override onCreate(): void { + this.suppressWarning(NET_CONNECTED, NET_DISCONNECTED, NET_RTT_UPDATE, NET_ENTITY_STATE) + } + + // ─── configuration ─────────────────────────────────────────────────────── + + /** + * Attach a transport. Call before `connect()`. + * Safe to swap transports at runtime (disconnects the old one first). + */ + setTransport(transport: Transport): this { + if (this._transport !== null) { + this._transport.onMessage = null + this._transport.onConnect = null + this._transport.onDisconnect = null + } + this._transport = transport + transport.onMessage = (data) => this._onMessage(data) + transport.onConnect = () => this._onConnect() + transport.onDisconnect = () => this._onDisconnect() + return this + } + + /** + * Wire RTT / loss / sent / recv into a `NetPanel`. + * Call after `dbg.addPanel('net', NetPanel)` and before `connect()`. + */ + bindDebugger(dbg: Debugger): this { + this._panel = dbg.getPanel('net') + return this + } + + // ─── connection ────────────────────────────────────────────────────────── + + connect(): this { + this._transport?.connect() + return this + } + + disconnect(): this { + this._transport?.disconnect() + return this + } + + get isConnected(): boolean { + return this._transport?.connected ?? false + } + + // ─── telemetry ─────────────────────────────────────────────────────────── + + /** Last measured round-trip time (ms). */ + get rtt(): number { return this._currentRTT } + + /** Packet-loss percentage (0–100) based on unanswered pings. */ + get loss(): number { + return this._totalPings === 0 ? 0 : (this._lostPings / this._totalPings) * 100 + } + + // ─── entity sync ───────────────────────────────────────────────────────── + + /** + * Send the current state of a local entity. + * + * The first call for a given `id` sends a full snapshot; subsequent calls + * send only the fields that changed (delta encoding). On the receiving + * side, deltas are merged onto the last known snapshot before storage. + * + * Call every frame (or whenever state changes) — no-ops when connected + * with no changes to send. + */ + syncEntity(id: string, state: Record): this { + if (!this.isConnected) return this + + const local = this._localEntities.get(id) + const seq = this._netSeq++ + + if (local === undefined) { + this._localEntities.set(id, { lastSentState: { ...state }, predictedState: null }) + this._sendMessage(MSG_SYNC, { id, seq, state, full: true }) + this._panel?.sent('sync') + return this + } + + // Delta: only send changed fields + const delta: Record = {} + let hasChanges = false + for (const k in state) { + if (state[k] !== local.lastSentState[k]) { + delta[k] = state[k] + hasChanges = true + } + } + if (!hasChanges) return this + + Object.assign(local.lastSentState, state) + this._sendMessage(MSG_SYNC, { id, seq, state: delta, full: false }) + this._panel?.sent('sync') + return this + } + + /** + * Return the latest received state for a remote entity, or `null` if + * no state has arrived yet. + */ + getEntityState>(id: string): T | null { + const remote = this._remoteEntities.get(id) + if (!remote || remote.buffer.length === 0) return null + return remote.buffer[remote.buffer.length - 1]!.state as T + } + + /** + * Return an interpolated state for a remote entity based on a rolling + * render-delay buffer. Numeric fields are linearly interpolated between + * the two buffered samples that straddle `(now - renderDelay)`. + * + * Falls back to the latest sample when the buffer doesn't yet contain + * two samples that straddle the target time. + */ + interpolateEntity>( + id: string, + renderDelay?: number + ): T | null { + const remote = this._remoteEntities.get(id) + if (!remote || remote.buffer.length === 0) return null + + const delay = renderDelay ?? this.interpolationDelay + const renderTime = Date.now() - delay + const buf = remote.buffer + + if (buf.length === 1 || buf[0]!.ts >= renderTime) { + return buf[0]!.state as T + } + + let prev = buf[0]! + let next = buf[buf.length - 1]! + + for (let i = 1; i < buf.length; i++) { + if (buf[i]!.ts >= renderTime) { + prev = buf[i - 1]! + next = buf[i]! + break + } + } + + const span = next.ts - prev.ts + const t = span <= 0 ? 1 : Math.min(1, (renderTime - prev.ts) / span) + + const result: Record = { ...prev.state } + for (const k in next.state) { + const pv = prev.state[k] + const nv = next.state[k] + result[k] = typeof pv === 'number' && typeof nv === 'number' + ? pv + (nv - pv) * t + : nv + } + return result as T + } + + /** + * Optimistically apply a predicted state for an entity. + * Retrieve with `getPredicted(id)`. + * + * Reconciliation pattern: apply the prediction immediately so the local + * display is responsive; when the authoritative server state arrives via + * `getEntityState`, compare and snap to the server value if diverged. + */ + predict>(id: string, state: T): this { + const local = this._localEntities.get(id) ?? { lastSentState: {}, predictedState: null } + local.predictedState = state as Record + this._localEntities.set(id, local) + return this + } + + /** + * Return the latest predicted state for `id`, or `null` if no prediction + * was applied. + */ + getPredicted>(id: string): T | null { + return (this._localEntities.get(id)?.predictedState as T | null | undefined) ?? null + } + + // ─── lifecycle ─────────────────────────────────────────────────────────── + + override onUpdate(_time: number, delta: number): void { + if (!this.isConnected) return + + this._pingTimer += delta + if (this._pingTimer >= this.pingIntervalMs) { + this._pingTimer -= this.pingIntervalMs + this._sendPing() + } + + // Expire unanswered pings + const now = Date.now() + for (const [seq, sendTime] of this._pendingPings) { + if (now - sendTime > this.pingTimeoutMs) { + this._pendingPings.delete(seq) + this._lostPings++ + } + } + + this._panel?.reportLoss(this.loss) + } + + override onDestroy(): void { + if (this._transport !== null) { + this._transport.onMessage = null + this._transport.onConnect = null + this._transport.onDisconnect = null + this._transport = null + } + this._pendingPings.clear() + this._remoteEntities.clear() + this._localEntities.clear() + } + + // ─── internals ─────────────────────────────────────────────────────────── + + private _onConnect(): void { + this.logger.info('transport connected') + this._pingTimer = this.pingIntervalMs // send first ping immediately + this.emit(NET_CONNECTED) + } + + private _onDisconnect(): void { + this.logger.info('transport disconnected') + this._pendingPings.clear() + this.emit(NET_DISCONNECTED) + } + + private _sendPing(): void { + const seq = this._pingSeq++ + const t = Date.now() + this._pendingPings.set(seq, t) + this._totalPings++ + this._sendMessage(MSG_PING, { seq, t }) + this._panel?.sent('ping') + } + + private _onMessage(data: Uint8Array): void { + if (data.length === 0) return + const type = data[0]! + const payload = JSON.parse(_dec.decode(data.subarray(1))) + + if (type === MSG_PING) { + // Echo back as pong (same payload contains the original sendTime) + this._sendMessage(MSG_PONG, payload) + this._panel?.received('ping') + } else if (type === MSG_PONG) { + const { seq, t } = payload as { seq: number; t: number } + if (this._pendingPings.has(seq)) { + this._currentRTT = Date.now() - t + this._pendingPings.delete(seq) + this._panel?.reportRTT(this._currentRTT) + this._panel?.received('pong') + this.emit(NET_RTT_UPDATE, this._currentRTT) + } + } else if (type === MSG_SYNC) { + const { id, seq, state, full } = payload as { + id: string + seq: number + state: Record + full: boolean + } + + const remote = this._remoteEntities.get(id) ?? { buffer: [] } + const baseState = (!full && remote.buffer.length > 0) + ? { ...remote.buffer[remote.buffer.length - 1]!.state } + : {} + Object.assign(baseState, state) + + remote.buffer.push({ state: baseState, ts: Date.now() }) + if (remote.buffer.length > this.maxStateBuffer) remote.buffer.shift() + this._remoteEntities.set(id, remote) + + this._sendMessage(MSG_ACK, { seq }) + this._panel?.received('sync') + this.emit(NET_ENTITY_STATE, id, baseState) + } else if (type === MSG_ACK) { + // Acknowledgement received — could be used for reliable delivery + this._panel?.received('ack') + } + } + + private _sendMessage(type: number, payload: unknown): void { + if (!this.isConnected || this._transport === null) return + const json = _enc.encode(JSON.stringify(payload)) + const buf = new Uint8Array(1 + json.length) + buf[0] = type + buf.set(json, 1) + this._transport.send(buf) + } +} diff --git a/phaser-plus/src/net/Transport.ts b/phaser-plus/src/net/Transport.ts new file mode 100644 index 00000000..e3025edf --- /dev/null +++ b/phaser-plus/src/net/Transport.ts @@ -0,0 +1,156 @@ +/** + * Minimal transport contract consumed by `NetFeature`. + * + * Implementations: `LoopbackTransport` (in-process, for tests + demos), + * `WebSocketTransport` (native browser WebSocket). + */ +export interface Transport { + readonly connected: boolean + connect(): void + disconnect(): void + send(data: Uint8Array): void + onMessage: ((data: Uint8Array) => void) | null + onConnect: (() => void) | null + onDisconnect: (() => void) | null +} + +/** + * In-process transport that links two endpoints in a matched pair. + * Messages sent on one side are delivered to the other after `latencyMs` + * (± `jitterMs`) using `setTimeout`. Deterministic jitter requires the + * caller to supply a seeded PRNG via the optional `rng` argument. + * + * ```ts + * const [clientTransport, serverTransport] = LoopbackTransport.pair(50, 10) + * ``` + */ +export class LoopbackTransport implements Transport { + + static pair( + latencyMs = 0, + jitterMs = 0, + rng: () => number = Math.random + ): [LoopbackTransport, LoopbackTransport] { + const a = new LoopbackTransport(latencyMs, jitterMs, rng) + const b = new LoopbackTransport(latencyMs, jitterMs, rng) + a._peer = b + b._peer = a + return [a, b] + } + + private _connected = false + private _peer: LoopbackTransport | null = null + private readonly _latencyMs: number + private readonly _jitterMs: number + private readonly _rng: () => number + private readonly _timers: Set> = new Set() + + onMessage: ((data: Uint8Array) => void) | null = null + onConnect: (() => void) | null = null + onDisconnect: (() => void) | null = null + + constructor(latencyMs = 0, jitterMs = 0, rng: () => number = Math.random) { + this._latencyMs = latencyMs + this._jitterMs = jitterMs + this._rng = rng + } + + get connected(): boolean { + return this._connected + } + + connect(): void { + if (this._connected) return + this._connected = true + this.onConnect?.() + if (this._peer !== null && !this._peer._connected) { + this._peer._connected = true + this._peer.onConnect?.() + } + } + + disconnect(): void { + if (!this._connected) return + this._connected = false + this._clearTimers() + this.onDisconnect?.() + if (this._peer !== null && this._peer._connected) { + this._peer._connected = false + this._peer._clearTimers() + this._peer.onDisconnect?.() + } + } + + send(data: Uint8Array): void { + if (!this._connected || this._peer === null) return + const jitter = this._jitterMs > 0 ? (this._rng() * 2 - 1) * this._jitterMs : 0 + const delay = Math.max(0, this._latencyMs + jitter) + const copy = new Uint8Array(data) + const peer = this._peer + const id = setTimeout(() => { + this._timers.delete(id) + if (peer._connected) peer.onMessage?.(copy) + }, delay) + this._timers.add(id) + } + + private _clearTimers(): void { + for (const id of this._timers) clearTimeout(id) + this._timers.clear() + } +} + +/** + * Native browser `WebSocket` wrapper. + * + * ```ts + * const transport = new WebSocketTransport('wss://example.com/game') + * ``` + */ +export class WebSocketTransport implements Transport { + + private _socket: WebSocket | null = null + private readonly _url: string + + onMessage: ((data: Uint8Array) => void) | null = null + onConnect: (() => void) | null = null + onDisconnect: (() => void) | null = null + + private static readonly _enc = new TextEncoder() + + constructor(url: string) { + this._url = url + } + + get connected(): boolean { + return this._socket !== null && this._socket.readyState === WebSocket.OPEN + } + + connect(): void { + if (this._socket !== null) return + const socket = new WebSocket(this._url) + socket.binaryType = 'arraybuffer' + socket.onopen = () => this.onConnect?.() + socket.onclose = () => { + this._socket = null + this.onDisconnect?.() + } + socket.onmessage = (event: MessageEvent) => { + const raw: unknown = event.data + const data = raw instanceof ArrayBuffer + ? new Uint8Array(raw) + : WebSocketTransport._enc.encode(String(raw)) + this.onMessage?.(data) + } + this._socket = socket + } + + disconnect(): void { + this._socket?.close() + this._socket = null + } + + send(data: Uint8Array): void { + if (this.connected) this._socket!.send(data) + } +} diff --git a/phaser-plus/src/net/index.ts b/phaser-plus/src/net/index.ts new file mode 100644 index 00000000..102dc9b8 --- /dev/null +++ b/phaser-plus/src/net/index.ts @@ -0,0 +1,20 @@ +import NetFeature, { + NET_CONNECTED, + NET_DISCONNECTED, + NET_RTT_UPDATE, + NET_ENTITY_STATE +} from './NetFeature' +import { LoopbackTransport, WebSocketTransport } from './Transport' +import type { Transport } from './Transport' + +export { + NetFeature, + NET_CONNECTED, + NET_DISCONNECTED, + NET_RTT_UPDATE, + NET_ENTITY_STATE, + LoopbackTransport, + WebSocketTransport +} + +export type { Transport } diff --git a/phaser-plus/src/particles/ParticleFeature.ts b/phaser-plus/src/particles/ParticleFeature.ts new file mode 100644 index 00000000..5a1a4f9c --- /dev/null +++ b/phaser-plus/src/particles/ParticleFeature.ts @@ -0,0 +1,239 @@ +import Feature from '../features/Feature' +import EffectManager from '../effects/EffectManager' +import type Effect from '../effects/Effect' +import type Scene from '../engine/Scene' + +type EffectCls = (new (camera: any) => Effect) & { KEY: string; FRAGMENT: string } + +export interface ParticlePreset { + texture: string + frame?: string | number + lifespan?: number + quantity?: number + speed?: { min: number; max: number } | number + scale?: { start: number; end: number } | number + alpha?: { start: number; end: number } | number + tint?: number | number[] + angle?: { min: number; max: number } + gravityX?: number + gravityY?: number + blendMode?: number + effect?: EffectCls + effectDuration?: number +} + +export interface StreamHandle { + stop(): void + readonly active: boolean +} + +export const PARTICLE_BURST = 'particle_feature.burst' +export const PARTICLE_STREAM_START = 'particle_feature.stream_start' +export const PARTICLE_STREAM_STOP = 'particle_feature.stream_stop' + +interface PresetEntry { + preset: ParticlePreset + free: any[] +} + +interface ActiveEffect { + instance: Effect + elapsed: number + duration: number +} + +class StreamHandleImpl implements StreamHandle { + + active: boolean = true + + private emitterRef: any + private stopCb: () => void + + constructor(emitterRef: any, stopCb: () => void) { + this.emitterRef = emitterRef + this.stopCb = stopCb + } + + stop(): void { + if (!this.active) return + this.active = false + const e = this.emitterRef + if (e !== null) { + if (typeof e.stopFollow === 'function') e.stopFollow() + if (typeof e.stop === 'function') e.stop() + else e.emitting = false + } + this.stopCb() + } + +} + +class ParticleFeature extends Feature { + + private presets: Map = new Map() + + private effectMgr: EffectManager | null = null + + private activeEffects: Map = new Map() + + constructor(scene: Scene, key: string) { + super(scene, key) + } + + setEffectTarget(target: any): this { + this.effectMgr = new EffectManager(target) + return this + } + + define(name: string, preset: ParticlePreset): this { + if (this.presets.has(name)) { + throw new Error(`particle-feature: preset "${name}" already defined`) + } + this.presets.set(name, { preset, free: [] }) + return this + } + + burst(name: string, x: number, y: number): void { + const entry = this.presets.get(name) + if (entry === undefined) { + this.logger.warning(`burst: preset "${name}" not defined`) + return + } + const { preset } = entry + const lifespan = preset.lifespan ?? 800 + const quantity = preset.quantity ?? 20 + + const emitter = this.obtainEmitter(entry, preset) + this.placeEmitter(emitter, x, y) + + if (typeof emitter.explode === 'function') { + emitter.explode(quantity) + } + + this.scene.time.delayedCall(lifespan + 200, () => { + entry.free.push(emitter) + }) + + this.emit(PARTICLE_BURST, name, x, y) + + if (preset.effect !== undefined && this.effectMgr !== null) { + this.activateEffect(name, preset) + } + } + + stream(name: string, follow: any): StreamHandle { + const entry = this.presets.get(name) + if (entry === undefined) { + this.logger.warning(`stream: preset "${name}" not defined`) + const dead = new StreamHandleImpl(null, () => {}) + dead.stop() + return dead + } + const { preset } = entry + const emitter = this.obtainEmitter(entry, preset) + + if (typeof emitter.start === 'function') { + emitter.start() + } else { + emitter.emitting = true + } + + if (typeof emitter.startFollow === 'function') { + emitter.startFollow(follow) + } else { + this.placeEmitter(emitter, follow.x ?? 0, follow.y ?? 0) + } + + this.emit(PARTICLE_STREAM_START, name) + + if (preset.effect !== undefined && this.effectMgr !== null) { + this.activateEffect(name, preset) + } + + return new StreamHandleImpl(emitter, () => { + entry.free.push(emitter) + this.emit(PARTICLE_STREAM_STOP, name) + }) + } + + override onCreate(): void { + this.suppressWarning(PARTICLE_BURST, PARTICLE_STREAM_START, PARTICLE_STREAM_STOP) + } + + override onUpdate(_time: number, delta: number): void { + const toRemove: string[] = [] + for (const [name, ae] of this.activeEffects) { + ae.elapsed += delta + if (ae.elapsed >= ae.duration) { + if (this.effectMgr !== null) { + this.effectMgr.remove(ae.instance) + } + toRemove.push(name) + } + } + for (const name of toRemove) { + this.activeEffects.delete(name) + } + } + + override onDestroy(): void { + for (const ae of this.activeEffects.values()) { + if (this.effectMgr !== null) { + this.effectMgr.remove(ae.instance) + } + } + for (const entry of this.presets.values()) { + for (const e of entry.free) { + if (typeof e.destroy === 'function') e.destroy() + } + } + this.presets.clear() + this.activeEffects.clear() + this.effectMgr = null + } + + private obtainEmitter(entry: PresetEntry, preset: ParticlePreset): any { + return entry.free.length > 0 ? entry.free.pop() : this.createEmitter(preset) + } + + private placeEmitter(emitter: any, x: number, y: number): void { + if (typeof emitter.setPosition === 'function') { + emitter.setPosition(x, y) + } else { + emitter.x = x + emitter.y = y + } + } + + private createEmitter(preset: ParticlePreset): any { + const cfg: Record = { + lifespan: preset.lifespan ?? 800, + quantity: preset.quantity ?? 20, + emitting: false + } + if (preset.speed !== undefined) cfg.speed = preset.speed + if (preset.scale !== undefined) cfg.scale = preset.scale + if (preset.alpha !== undefined) cfg.alpha = preset.alpha + if (preset.tint !== undefined) cfg.tint = preset.tint + if (preset.angle !== undefined) cfg.angle = preset.angle + if (preset.gravityX !== undefined) cfg.gravityX = preset.gravityX + if (preset.gravityY !== undefined) cfg.gravityY = preset.gravityY + if (preset.blendMode !== undefined) cfg.blendMode = preset.blendMode + if (preset.frame !== undefined) cfg.frame = preset.frame + + return (this.scene as any).add.particles(0, 0, preset.texture, cfg) + } + + private activateEffect(name: string, preset: ParticlePreset): void { + const prev = this.activeEffects.get(name) + if (prev !== undefined) { + this.effectMgr!.remove(prev.instance) + } + const duration = preset.effectDuration ?? (preset.lifespan ?? 800) * 1.5 + const instance = this.effectMgr!.add(preset.effect as any) + this.activeEffects.set(name, { instance, elapsed: 0, duration }) + } + +} + +export default ParticleFeature diff --git a/phaser-plus/src/particles/index.ts b/phaser-plus/src/particles/index.ts new file mode 100644 index 00000000..d2a3aa7b --- /dev/null +++ b/phaser-plus/src/particles/index.ts @@ -0,0 +1,19 @@ +import ParticleFeature from './ParticleFeature' +import type { ParticlePreset, StreamHandle } from './ParticleFeature' +import { + PARTICLE_BURST, + PARTICLE_STREAM_START, + PARTICLE_STREAM_STOP +} from './ParticleFeature' + +export { + ParticleFeature, + PARTICLE_BURST, + PARTICLE_STREAM_START, + PARTICLE_STREAM_STOP +} + +export type { + ParticlePreset, + StreamHandle +} diff --git a/phaser-plus/src/persistence/IndexedDBBackend.ts b/phaser-plus/src/persistence/IndexedDBBackend.ts new file mode 100644 index 00000000..7c77c018 --- /dev/null +++ b/phaser-plus/src/persistence/IndexedDBBackend.ts @@ -0,0 +1,76 @@ +import type { SaveBackend } from './SaveBackend' + +class IndexedDBBackend implements SaveBackend { + + private readonly dbName: string + private readonly storeName: string = 'saves' + private db: IDBDatabase | null = null + private readonly ready: Promise + + constructor(dbName: string = 'phaser-plus') { + this.dbName = dbName + this.ready = this.open() + } + + private open(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(this.dbName, 1) + req.onupgradeneeded = () => { + req.result.createObjectStore(this.storeName) + } + req.onsuccess = () => { + this.db = req.result + resolve() + } + req.onerror = () => reject(req.error) + }) + } + + private store(mode: IDBTransactionMode): IDBObjectStore { + return this.db!.transaction(this.storeName, mode).objectStore(this.storeName) + } + + async save(key: string, value: string): Promise { + await this.ready + return new Promise((resolve, reject) => { + const req = this.store('readwrite').put(value, key) + req.onsuccess = () => resolve() + req.onerror = () => reject(req.error) + }) + } + + async load(key: string): Promise { + await this.ready + return new Promise((resolve, reject) => { + const req = this.store('readonly').get(key) + req.onsuccess = () => resolve((req.result as string | undefined) ?? null) + req.onerror = () => reject(req.error) + }) + } + + async delete(key: string): Promise { + await this.ready + return new Promise((resolve, reject) => { + const req = this.store('readwrite').delete(key) + req.onsuccess = () => resolve() + req.onerror = () => reject(req.error) + }) + } + + async keys(): Promise { + await this.ready + return new Promise((resolve, reject) => { + const req = this.store('readonly').getAllKeys() + req.onsuccess = () => resolve(req.result as string[]) + req.onerror = () => reject(req.error) + }) + } + + dispose(): void { + this.db?.close() + this.db = null + } + +} + +export default IndexedDBBackend diff --git a/phaser-plus/src/persistence/LocalStorageBackend.ts b/phaser-plus/src/persistence/LocalStorageBackend.ts new file mode 100644 index 00000000..0f823011 --- /dev/null +++ b/phaser-plus/src/persistence/LocalStorageBackend.ts @@ -0,0 +1,42 @@ +import type { SaveBackend } from './SaveBackend' + +class LocalStorageBackend implements SaveBackend { + + private readonly prefix: string + + constructor(appPrefix: string = 'phaser-plus') { + this.prefix = appPrefix + ':' + } + + private storageKey(key: string): string { + return this.prefix + key + } + + async save(key: string, value: string): Promise { + localStorage.setItem(this.storageKey(key), value) + } + + async load(key: string): Promise { + return localStorage.getItem(this.storageKey(key)) + } + + async delete(key: string): Promise { + localStorage.removeItem(this.storageKey(key)) + } + + async keys(): Promise { + const result: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k !== null && k.startsWith(this.prefix)) { + result.push(k.slice(this.prefix.length)) + } + } + return result + } + + dispose(): void {} + +} + +export default LocalStorageBackend diff --git a/phaser-plus/src/persistence/MemoryBackend.ts b/phaser-plus/src/persistence/MemoryBackend.ts new file mode 100644 index 00000000..68793792 --- /dev/null +++ b/phaser-plus/src/persistence/MemoryBackend.ts @@ -0,0 +1,29 @@ +import type { SaveBackend } from './SaveBackend' + +class MemoryBackend implements SaveBackend { + + private readonly store: Map = new Map() + + async save(key: string, value: string): Promise { + this.store.set(key, value) + } + + async load(key: string): Promise { + return this.store.get(key) ?? null + } + + async delete(key: string): Promise { + this.store.delete(key) + } + + async keys(): Promise { + return Array.from(this.store.keys()) + } + + dispose(): void { + this.store.clear() + } + +} + +export default MemoryBackend diff --git a/phaser-plus/src/persistence/PersistenceFeature.ts b/phaser-plus/src/persistence/PersistenceFeature.ts new file mode 100644 index 00000000..cba8ef68 --- /dev/null +++ b/phaser-plus/src/persistence/PersistenceFeature.ts @@ -0,0 +1,57 @@ +import Feature from '../features/Feature' +import type Scene from '../engine/Scene' +import SaveService from './SaveService' +import type { SaveEntry } from './SaveService' + +export const SAVE_DONE = 'persistence_feature.save_done' +export const LOAD_DONE = 'persistence_feature.load_done' +export const SAVE_DELETED = 'persistence_feature.save_deleted' + +class PersistenceFeature extends Feature { + + private _service: SaveService | null = null + + constructor(scene: Scene, key: string) { + super(scene, key) + } + + get service(): SaveService { + if (this._service === null) throw new Error(`PersistenceFeature(${this.key}) not yet created`) + return this._service + } + + override onCreate(): void { + this._service = this.scene.services.resolve(SaveService) + } + + async save(slotId: string, data: unknown): Promise { + await this.service.save(slotId, data) + this.emit(SAVE_DONE, slotId, data) + } + + async load(slotId: string): Promise { + const result = await this.service.load(slotId) + this.emit(LOAD_DONE, slotId, result) + return result + } + + async delete(slotId: string): Promise { + await this.service.delete(slotId) + this.emit(SAVE_DELETED, slotId) + } + + async list(): Promise { + return this.service.list() + } + + async has(slotId: string): Promise { + return this.service.has(slotId) + } + + override onDestroy(): void { + this._service = null + } + +} + +export default PersistenceFeature diff --git a/phaser-plus/src/persistence/SaveBackend.ts b/phaser-plus/src/persistence/SaveBackend.ts new file mode 100644 index 00000000..809f8855 --- /dev/null +++ b/phaser-plus/src/persistence/SaveBackend.ts @@ -0,0 +1,7 @@ +export interface SaveBackend { + save(key: string, value: string): Promise + load(key: string): Promise + delete(key: string): Promise + keys(): Promise + dispose(): void +} diff --git a/phaser-plus/src/persistence/SaveService.ts b/phaser-plus/src/persistence/SaveService.ts new file mode 100644 index 00000000..ddaa3d2b --- /dev/null +++ b/phaser-plus/src/persistence/SaveService.ts @@ -0,0 +1,116 @@ +import type { Disposable } from '../features/ServiceRegistry' +import type { SaveBackend } from './SaveBackend' +import LocalStorageBackend from './LocalStorageBackend' + +export interface SaveSchema { + version: number + migrations?: Record unknown> +} + +export interface SaveEntry { + id: string + version: number + savedAt: number +} + +export interface SaveConfig { + backend?: SaveBackend + namespace?: string + schema?: SaveSchema +} + +interface SaveEnvelope { + _version: number + _savedAt: number + data: unknown +} + +class SaveService implements Disposable { + + private readonly backend: SaveBackend + private readonly namespace: string + private schema: SaveSchema + + constructor(config: SaveConfig = {}) { + this.backend = config.backend ?? new LocalStorageBackend() + this.namespace = config.namespace ?? 'save' + this.schema = config.schema ?? { version: 1 } + } + + setSchema(schema: SaveSchema): this { + this.schema = schema + return this + } + + private slotKey(slotId: string): string { + return `${this.namespace}:${slotId}` + } + + async save(slotId: string, data: unknown): Promise { + const envelope: SaveEnvelope = { + _version: this.schema.version, + _savedAt: Date.now(), + data + } + await this.backend.save(this.slotKey(slotId), JSON.stringify(envelope)) + } + + async load(slotId: string): Promise { + const raw = await this.backend.load(this.slotKey(slotId)) + if (raw === null) return null + const envelope = JSON.parse(raw) as SaveEnvelope + return this.migrate(envelope) as T + } + + async delete(slotId: string): Promise { + await this.backend.delete(this.slotKey(slotId)) + } + + async list(): Promise { + const allKeys = await this.backend.keys() + const prefix = this.namespace + ':' + const slotKeys = allKeys.filter(k => k.startsWith(prefix)) + const entries: SaveEntry[] = [] + for (const k of slotKeys) { + const raw = await this.backend.load(k) + if (raw === null) continue + try { + const envelope = JSON.parse(raw) as SaveEnvelope + entries.push({ + id: k.slice(prefix.length), + version: envelope._version, + savedAt: envelope._savedAt + }) + } catch { + // skip corrupt entries + } + } + return entries + } + + async has(slotId: string): Promise { + return (await this.backend.load(this.slotKey(slotId))) !== null + } + + private migrate(envelope: SaveEnvelope): unknown { + const { migrations } = this.schema + if (!migrations) return envelope.data + let version = envelope._version + let data = envelope.data + while (version < this.schema.version) { + const migrator = migrations[version] + if (migrator !== undefined) { + data = migrator(data) + } + version++ + } + return data + } + + dispose(): void { + this.backend.dispose() + } + +} + +export default SaveService diff --git a/phaser-plus/src/persistence/index.ts b/phaser-plus/src/persistence/index.ts new file mode 100644 index 00000000..fd876fb7 --- /dev/null +++ b/phaser-plus/src/persistence/index.ts @@ -0,0 +1,19 @@ +import type { SaveBackend } from './SaveBackend' +import MemoryBackend from './MemoryBackend' +import LocalStorageBackend from './LocalStorageBackend' +import IndexedDBBackend from './IndexedDBBackend' +import SaveService from './SaveService' +import type { SaveSchema, SaveEntry, SaveConfig } from './SaveService' +import PersistenceFeature, { SAVE_DONE, LOAD_DONE, SAVE_DELETED } from './PersistenceFeature' + +export type { SaveBackend, SaveSchema, SaveEntry, SaveConfig } +export { + MemoryBackend, + LocalStorageBackend, + IndexedDBBackend, + SaveService, + PersistenceFeature, + SAVE_DONE, + LOAD_DONE, + SAVE_DELETED +} diff --git a/phaser-plus/src/perspective2d/GameObject2D.ts b/phaser-plus/src/perspective2d/GameObject2D.ts index 21437728..b778783a 100644 --- a/phaser-plus/src/perspective2d/GameObject2D.ts +++ b/phaser-plus/src/perspective2d/GameObject2D.ts @@ -21,8 +21,9 @@ export default class GameObject2D extends GameObject { } setTransform(x: number, y: number): this { - this.projection.translate(x, y, this as unknown as M.Vector2) + this.projection.transformPoint(x, y, this as unknown as M.Vector2) this.transform.set(x, y) + this.pivot.set(x, y) return this } @@ -46,7 +47,7 @@ export default class GameObject2D extends GameObject { override doUpdate(time: number, delta: number): void { super.doUpdate(time, delta) - this.projection.inverse.translate(this.x, this.y, this.transform) + this.projection.inverse.transformPoint(this.x, this.y, this.transform) this.pivot.set(this.transform.x, this.transform.y) } diff --git a/phaser-plus/src/perspective2d/Grid.ts b/phaser-plus/src/perspective2d/Grid.ts index b5a7e4b0..ac1a3640 100644 --- a/phaser-plus/src/perspective2d/Grid.ts +++ b/phaser-plus/src/perspective2d/Grid.ts @@ -40,7 +40,11 @@ export default class Grid extends GameObject { this.canvas.lineStyle(1, this.STYLE.LINES, 0.3) } - override onDestroy(): void {} + override onDestroy(): void { + if (this.TEXTURE_KEY !== '' && this.scene.textures.exists(this.TEXTURE_KEY)) { + this.scene.textures.remove(this.TEXTURE_KEY) + } + } setProjection(matrix: Matrix2): void { this.canvas = this.scene.add.graphics() @@ -80,10 +84,10 @@ export default class Grid extends GameObject { this.canvas.fillRect(0, 0, tile.x, tile.y) for (let x = -polygons; x < polygons; x++) { for (let y = -polygons; y < polygons; y++) { - matrix.translate(x, y, pointA) - matrix.translate(x, y + 1, pointB) - matrix.translate(x + 1, y, pointC) - matrix.translate(x + 1, y + 1, pointD) + matrix.transformPoint(x, y, pointA) + matrix.transformPoint(x, y + 1, pointB) + matrix.transformPoint(x + 1, y, pointC) + matrix.transformPoint(x + 1, y + 1, pointD) this.canvas.beginPath() this.canvas.moveTo(pointA.x, pointA.y) this.canvas.lineTo(pointB.x, pointB.y) @@ -97,18 +101,18 @@ export default class Grid extends GameObject { private drawLinearTiles(matrix: Matrix2): void { const crop = new M.Vector2(0, 0) - matrix.translate(2, 2, crop) + matrix.transformPoint(2, 2, crop) this.canvas.fillRect(0, 0, crop.x, crop.y) this.canvas.lineStyle(1, 0xffffff, 0.2) this.canvas.strokePoints([ - matrix.translate(0.8, 1), - matrix.translate(1.2, 1) + matrix.transformPoint(0.8, 1), + matrix.transformPoint(1.2, 1) ]) this.canvas.strokePoints([ - matrix.translate(1, 0.8), - matrix.translate(1, 1.2) + matrix.transformPoint(1, 0.8), + matrix.transformPoint(1, 1.2) ]) this.canvas.generateTexture(this.TEXTURE_KEY, crop.x, crop.y) } @@ -121,7 +125,7 @@ export default class Grid extends GameObject { } private getProjectionTileSize(matrix: Matrix2): M.Vector2 { - const refPoint = new M.Vector2(matrix.translate(1, 0).x, matrix.translate(0, 1).y) + const refPoint = new M.Vector2(matrix.transformPoint(1, 0).x, matrix.transformPoint(0, 1).y) refPoint.x = Math.abs(refPoint.x) refPoint.y = Math.abs(refPoint.y) @@ -133,7 +137,7 @@ export default class Grid extends GameObject { while (shiftX < this.TILE_PRECISION) { x += refPoint.x shiftX++ - matrix.inverse.translate(x, 0, tempPoint) + matrix.inverse.transformPoint(x, 0, tempPoint) tempPoint.x = Math.round(tempPoint.x * 10) / 10 tempPoint.y = Math.round(tempPoint.y * 10) / 10 if (tempPoint.y % 1 === 0) { @@ -147,7 +151,7 @@ export default class Grid extends GameObject { while (shiftY < this.TILE_PRECISION) { y += refPoint.y shiftY++ - matrix.inverse.translate(0, y, tempPoint) + matrix.inverse.transformPoint(0, y, tempPoint) tempPoint.x = Math.round(tempPoint.x * 10) / 10 tempPoint.y = Math.round(tempPoint.y * 10) / 10 if (tempPoint.x % 1 === 0 && tempPoint.y % 1 === 0) { diff --git a/phaser-plus/src/perspective2d/World.ts b/phaser-plus/src/perspective2d/World.ts index 3bf45f44..9796c9d6 100644 --- a/phaser-plus/src/perspective2d/World.ts +++ b/phaser-plus/src/perspective2d/World.ts @@ -34,6 +34,11 @@ export default class World extends Layer { this.onLayerUpdate() } + override onUpdate(time: number, delta: number): void { + super.onUpdate(time, delta) + ;(this.container.list as unknown as GameObject2D[]).sort(this.sort.fn) + } + protected override onLayerUpdate(): void { super.onLayerUpdate() this.grid.cameraFilter = this.container.cameraFilter @@ -46,7 +51,6 @@ export default class World extends Layer { } this.gridUpdate = null } - ;(this.container.list as unknown as GameObject2D[]).sort(this.sort.fn) const matter = (this.scene as any).matter if (matter !== undefined && matter.world.debugGraphic !== undefined) { diff --git a/phaser-plus/src/pool/GameObjectPool.ts b/phaser-plus/src/pool/GameObjectPool.ts index a508d9c1..0bb437f9 100644 --- a/phaser-plus/src/pool/GameObjectPool.ts +++ b/phaser-plus/src/pool/GameObjectPool.ts @@ -59,6 +59,10 @@ export default class GameObjectPool { key: string, gameObjectClass: GameObjectClass, instanceFn: InstanceFn | null = null, + /** + * Called on every reuse (not on first creation). Use this to clear per-instance + * state that should not carry over between lives — tint, scale, alpha, velocity, etc. + */ resetFn: ResetFn | null = null ): this { if (this.map.has(key)) { @@ -70,7 +74,11 @@ export default class GameObjectPool { const pool = new (ObjectPool as any)( gameObjectClass, resetFn, - (cls: GameObjectClass) => factory(key, cls, this.scene) + (cls: GameObjectClass) => { + const obj = factory(key, cls, this.scene) + ;(obj as any).__poolKey = key + return obj + } ) this.map.set(key, pool) this.dirty = true @@ -85,6 +93,8 @@ export default class GameObjectPool { } const object = pool.obtain() as T & Poolable this.onObjectCreate(object) + ;(object as any).__freed = false + object.setActive(true).setVisible(true) return object as T } @@ -93,12 +103,34 @@ export default class GameObjectPool { this.logger.warning('cannot be released, the object was not created by the pool', object) return this } + const key = (object as any).__poolKey + if (!key || !this.map.has(key)) { + this.logger.warning('object does not belong to this pool', object) + return this + } + if ((object as any).__freed === true) { + this.logger.warning('object already released', object) + return this + } + ;(object as any).__freed = true + ;(object.parentContainer ?? this.scene.children).remove(object as any) + object.setActive(false).setVisible(false) object.release() return this } dispose(): void { - this.map.forEach(pool => { (pool as any).dispose() }) + this.map.forEach(pool => { + // Destroy every free (not currently live) instance so their GL/texture refs are + // released immediately. Live objects that were obtained but not yet released are + // still held in the scene's display list and will be destroyed by Phaser's own + // scene teardown. + const free: any[] = (pool as any).pool ?? [] + for (const obj of free) { + if (typeof obj?.destroy === 'function') obj.destroy() + } + ;(pool as any).dispose() + }) this.map.clear() this.dirty = true } @@ -110,7 +142,9 @@ export default class GameObjectPool { private onObjectCreate(object: Poolable): void { if (typeof object.poolable === 'boolean') return object.poolable = true - if (typeof object.onCreate === 'function') { + if (typeof (object as any).doCreate === 'function') { + ;(object as any).doCreate() + } else if (typeof object.onCreate === 'function') { object.onCreate() } } diff --git a/phaser-plus/src/structs/AABB.ts b/phaser-plus/src/structs/AABB.ts new file mode 100644 index 00000000..b4846692 --- /dev/null +++ b/phaser-plus/src/structs/AABB.ts @@ -0,0 +1,66 @@ +export default class AABB { + x: number + y: number + width: number + height: number + + constructor(x = 0, y = 0, width = 0, height = 0) { + this.x = x + this.y = y + this.width = width + this.height = height + } + + get left(): number { return this.x } + get right(): number { return this.x + this.width } + get top(): number { return this.y } + get bottom(): number { return this.y + this.height } + get centerX(): number { return this.x + this.width * 0.5 } + get centerY(): number { return this.y + this.height * 0.5 } + + set(x: number, y: number, width: number, height: number): this { + this.x = x + this.y = y + this.width = width + this.height = height + return this + } + + reset(): this { + return this.set(0, 0, 0, 0) + } + + contains(px: number, py: number): boolean { + return px >= this.left && px <= this.right && py >= this.top && py <= this.bottom + } + + intersects(other: AABB): boolean { + return !( + other.right < this.left || + other.left > this.right || + other.bottom < this.top || + other.top > this.bottom + ) + } + + containsRect(other: AABB): boolean { + return other.left >= this.left && other.right <= this.right && + other.top >= this.top && other.bottom <= this.bottom + } + + copyFrom(other: AABB): this { + return this.set(other.x, other.y, other.width, other.height) + } + + clone(): AABB { + return new AABB(this.x, this.y, this.width, this.height) + } + + static from(rect: { x: number; y: number; width: number; height: number }): AABB { + return new AABB(rect.x, rect.y, rect.width, rect.height) + } + + static fromCenter(cx: number, cy: number, width: number, height: number): AABB { + return new AABB(cx - width * 0.5, cy - height * 0.5, width, height) + } +} diff --git a/phaser-plus/src/structs/Transform.ts b/phaser-plus/src/structs/Transform.ts new file mode 100644 index 00000000..b63aa93f --- /dev/null +++ b/phaser-plus/src/structs/Transform.ts @@ -0,0 +1,37 @@ +export default class Transform { + x: number = 0 + y: number = 0 + rotation: number = 0 + scaleX: number = 1 + scaleY: number = 1 + + set(x: number, y: number, rotation = 0, scaleX = 1, scaleY = 1): this { + this.x = x + this.y = y + this.rotation = rotation + this.scaleX = scaleX + this.scaleY = scaleY + return this + } + + reset(): this { + return this.set(0, 0, 0, 1, 1) + } + + copyFrom(other: Transform): this { + return this.set(other.x, other.y, other.rotation, other.scaleX, other.scaleY) + } + + clone(): Transform { + return new Transform().copyFrom(this) + } + + applyTo(target: { x: number; y: number; rotation: number; scaleX: number; scaleY: number }): this { + target.x = this.x + target.y = this.y + target.rotation = this.rotation + target.scaleX = this.scaleX + target.scaleY = this.scaleY + return this + } +} diff --git a/phaser-plus/src/structs/index.ts b/phaser-plus/src/structs/index.ts new file mode 100644 index 00000000..cd0e6379 --- /dev/null +++ b/phaser-plus/src/structs/index.ts @@ -0,0 +1,9 @@ +import { Spatial, Vec2, Easing, Random } from '@toolcase/base' +import AABB from './AABB' +import Transform from './Transform' + +const SpatialHash = Spatial.SpatialHash +const Quadtree = Spatial.Quadtree + +export { SpatialHash, Quadtree, Vec2, Easing, Random, AABB, Transform } +export type { SpatialPoint, SpatialRect, EasingFn, Rect } from '@toolcase/base' diff --git a/phaser-plus/src/tilemap/TilemapFeature.ts b/phaser-plus/src/tilemap/TilemapFeature.ts new file mode 100644 index 00000000..600b7374 --- /dev/null +++ b/phaser-plus/src/tilemap/TilemapFeature.ts @@ -0,0 +1,130 @@ +import type { Tilemaps } from 'phaser' +import type Scene from '../engine/Scene' +import Feature from '../features/Feature' +import ObjectLayer from '../features/ObjectLayer' +import TilemapNavMesh from '../ai/TilemapNavMesh' + +export interface BuildNavMeshOptions { + walkable: number[] + layer?: number | string +} + +export interface TilemapObject { + name: string + type: string + x: number + y: number + width: number + height: number + properties: Record +} + +class TilemapFeature extends Feature { + + private activeMap: Tilemaps.Tilemap | null = null + + private readonly tilesets: Map = new Map() + + private readonly layers: Map = new Map() + + constructor(scene: Scene, key: string) { + super(scene, key) + } + + loadTiled(key: string, url: string): this { + this.scene.load.tilemapTiledJSON(key, url) + return this + } + + loadLDtk(key: string, url: string): this { + this.scene.load.json(key, url) + return this + } + + create(key: string): this { + this.activeMap?.destroy() + this.tilesets.clear() + this.layers.clear() + this.activeMap = this.scene.make.tilemap({ key }) + return this + } + + createFromData(data: number[][], tileWidth: number, tileHeight: number): this { + this.activeMap?.destroy() + this.tilesets.clear() + this.layers.clear() + this.activeMap = this.scene.make.tilemap({ data, tileWidth, tileHeight }) + return this + } + + addTileset(tilesetName: string, textureKey: string): Tilemaps.Tileset { + if (this.activeMap === null) throw new Error('TilemapFeature: call create() or createFromData() first') + const ts = this.activeMap.addTilesetImage(tilesetName, textureKey) + if (ts === null) throw new Error(`TilemapFeature: tileset "${tilesetName}" not found in map data`) + this.tilesets.set(tilesetName, ts) + return ts + } + + createLayer(layerName: string | number, tilesetName?: string): Tilemaps.TilemapLayer { + if (this.activeMap === null) throw new Error('TilemapFeature: call create() or createFromData() first') + const tileset = tilesetName !== undefined ? (this.tilesets.get(tilesetName) ?? []) : [] + const layer = this.activeMap.createLayer(layerName, tileset as any, 0, 0) + if (layer === null) throw new Error(`TilemapFeature: layer "${layerName}" not found`) + if (typeof layerName === 'string') this.layers.set(layerName, layer) + return layer + } + + buildColliders(layerName: string | number, tilesetName?: string): Tilemaps.TilemapLayer { + const layer = this.createLayer(layerName, tilesetName) + layer.setCollisionByProperty({ collides: true }) + return layer + } + + buildNavMesh(options: BuildNavMeshOptions): TilemapNavMesh { + if (this.activeMap === null) throw new Error('TilemapFeature: call create() or createFromData() first') + return new TilemapNavMesh(this.activeMap, options.walkable, options.layer) + } + + toObjectLayer(featureKey: string, objectLayerName: string, poolKey: string): ObjectLayer { + if (this.activeMap === null) throw new Error('TilemapFeature: call create() or createFromData() first') + const layer = this.scene.features.register(featureKey, ObjectLayer) + const objLayer = this.activeMap.getObjectLayer(objectLayerName) + if (objLayer !== null) { + for (const obj of objLayer.objects) { + if (obj.x !== undefined && obj.y !== undefined) { + layer.add(poolKey, obj.x, obj.y) + } + } + } + return layer + } + + getObjects(objectLayerName: string): TilemapObject[] { + if (this.activeMap === null) throw new Error('TilemapFeature: call create() or createFromData() first') + const objLayer = this.activeMap.getObjectLayer(objectLayerName) + if (objLayer === null) return [] + return objLayer.objects.map(o => ({ + name: o.name ?? '', + type: (o as any).type ?? '', + x: o.x ?? 0, + y: o.y ?? 0, + width: o.width ?? 0, + height: o.height ?? 0, + properties: (o.properties as Record) ?? {} + })) + } + + get tilemap(): Tilemaps.Tilemap | null { + return this.activeMap + } + + override onDestroy(): void { + this.activeMap?.destroy() + this.activeMap = null + this.tilesets.clear() + this.layers.clear() + } + +} + +export default TilemapFeature diff --git a/phaser-plus/src/tilemap/index.ts b/phaser-plus/src/tilemap/index.ts new file mode 100644 index 00000000..51c442b1 --- /dev/null +++ b/phaser-plus/src/tilemap/index.ts @@ -0,0 +1,4 @@ +import TilemapFeature from './TilemapFeature' + +export { TilemapFeature } +export type { BuildNavMeshOptions, TilemapObject } from './TilemapFeature' diff --git a/phaser-plus/test/Feature.test.ts b/phaser-plus/test/Feature.test.ts new file mode 100644 index 00000000..e2ad2b6c --- /dev/null +++ b/phaser-plus/test/Feature.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Feature from '../src/features/Feature' + +function makeScene(listenerCount = 0): { scene: any, warnSpy: ReturnType, emitSpy: ReturnType } { + const warnSpy = vi.fn() + const emitSpy = vi.fn().mockReturnValue(true) + const logger = { warning: warnSpy } + const engine = { getLogger: vi.fn().mockReturnValue(logger) } + const features = { + listenerCount: vi.fn().mockReturnValue(listenerCount), + emit: emitSpy, + } + const scene = { game: {}, engine, features } + return { scene, warnSpy, emitSpy } +} + +class TestFeature extends Feature { + callEmit(event: string, ...args: unknown[]) { + this.emit(event, ...args) + } + + callSuppressWarning(...events: string[]) { + this.suppressWarning(...events) + } +} + +describe('Feature.emit', () => { + describe('no listener registered', () => { + it('warns by default when no listener is registered', () => { + const { scene, warnSpy } = makeScene(0) + const feature = new TestFeature(scene, 'test') + feature.callEmit('player:died', { score: 100 }) + expect(warnSpy).toHaveBeenCalledOnce() + expect(warnSpy.mock.calls[0][0]).toContain('player:died') + }) + + it('does not call features.emit when no listener is registered', () => { + const { scene, emitSpy } = makeScene(0) + const feature = new TestFeature(scene, 'test') + feature.callEmit('player:died') + expect(emitSpy).not.toHaveBeenCalled() + }) + + it('includes the event payload in the warning', () => { + const { scene, warnSpy } = makeScene(0) + const feature = new TestFeature(scene, 'test') + feature.callEmit('ui:ready', 'arg1', 42) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('ui:ready'), + 'arg1', + 42, + ) + }) + }) + + describe('suppressWarning', () => { + it('suppresses the warning for a single opted-out event', () => { + const { scene, warnSpy } = makeScene(0) + const feature = new TestFeature(scene, 'test') + feature.callSuppressWarning('analytics:purchase') + feature.callEmit('analytics:purchase', { amount: 9.99 }) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('still warns for events that were not opted-out', () => { + const { scene, warnSpy } = makeScene(0) + const feature = new TestFeature(scene, 'test') + feature.callSuppressWarning('analytics:purchase') + feature.callEmit('player:died') + expect(warnSpy).toHaveBeenCalledOnce() + }) + + it('suppresses warnings for multiple events passed in one call', () => { + const { scene, warnSpy } = makeScene(0) + const feature = new TestFeature(scene, 'test') + feature.callSuppressWarning('event:a', 'event:b') + feature.callEmit('event:a') + feature.callEmit('event:b') + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('does not emit to features when there are no listeners even after suppressWarning', () => { + const { scene, emitSpy } = makeScene(0) + const feature = new TestFeature(scene, 'test') + feature.callSuppressWarning('ambient:cue') + feature.callEmit('ambient:cue') + expect(emitSpy).not.toHaveBeenCalled() + }) + }) + + describe('with listeners', () => { + it('forwards the event and payload when listeners exist', () => { + const { scene, emitSpy } = makeScene(1) + const feature = new TestFeature(scene, 'test') + feature.callEmit('player:scored', 500) + expect(emitSpy).toHaveBeenCalledWith('player:scored', 500) + }) + + it('does not warn when listeners exist', () => { + const { scene, warnSpy } = makeScene(1) + const feature = new TestFeature(scene, 'test') + feature.callEmit('player:scored', 500) + expect(warnSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/phaser-plus/test/GameObjectComponent.test.ts b/phaser-plus/test/GameObjectComponent.test.ts new file mode 100644 index 00000000..900717db --- /dev/null +++ b/phaser-plus/test/GameObjectComponent.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi } from 'vitest' +import GameObject from '../src/engine/GameObject' +import GameObjectComponent from '../src/engine/GameObjectComponent' + +/** + * Build a minimal GameObject-like instance without invoking the Phaser + * Container constructor (which requires a live renderer). The private fields + * that the component bag depends on are initialised manually so all + * add / get / remove / lifecycle paths can be exercised in plain Node. + */ +function makeGameObject(): any { + const obj = Object.create(GameObject.prototype) as any + obj._components = new Map() + obj._created = false + // doUpdate iterates this.list — provide an empty stand-in + obj.list = [] + // onUpdate / onCreate / onDestroy are on the prototype (no-ops) + return obj +} + +class HealthComponent extends GameObjectComponent { + onCreate = vi.fn() + onUpdate = vi.fn() + onDestroy = vi.fn() + hp = 100 +} + +class MoverComponent extends GameObjectComponent { + onCreate = vi.fn() + onUpdate = vi.fn() + onDestroy = vi.fn() + speed = 5 +} + +describe('GameObject component bag', () => { + + describe('addComponent', () => { + it('returns a new instance of the component class', () => { + const obj = makeGameObject() + const health = obj.addComponent(HealthComponent) + expect(health).toBeInstanceOf(HealthComponent) + }) + + it('sets owner to the game object', () => { + const obj = makeGameObject() + const health = obj.addComponent(HealthComponent) + expect(health.owner).toBe(obj) + }) + + it('returns the same instance when called twice with the same class', () => { + const obj = makeGameObject() + const a = obj.addComponent(HealthComponent) + const b = obj.addComponent(HealthComponent) + expect(a).toBe(b) + }) + + it('does not call onCreate() when the object is not yet created', () => { + const obj = makeGameObject() + const health = obj.addComponent(HealthComponent) + expect(health.onCreate).not.toHaveBeenCalled() + }) + + it('calls onCreate() immediately when the object is already live', () => { + const obj = makeGameObject() + obj._created = true + const health = obj.addComponent(HealthComponent) + expect(health.onCreate).toHaveBeenCalledOnce() + }) + }) + + describe('getComponent', () => { + it('returns the component instance after addComponent', () => { + const obj = makeGameObject() + const health = obj.addComponent(HealthComponent) + expect(obj.getComponent(HealthComponent)).toBe(health) + }) + + it('returns null when the component has not been added', () => { + const obj = makeGameObject() + expect(obj.getComponent(HealthComponent)).toBeNull() + }) + + it('does not confuse components of different classes', () => { + const obj = makeGameObject() + obj.addComponent(HealthComponent) + expect(obj.getComponent(MoverComponent)).toBeNull() + }) + }) + + describe('removeComponent', () => { + it('calls onDestroy() on the removed component', () => { + const obj = makeGameObject() + const health = obj.addComponent(HealthComponent) + obj.removeComponent(HealthComponent) + expect(health.onDestroy).toHaveBeenCalledOnce() + }) + + it('makes getComponent return null after removal', () => { + const obj = makeGameObject() + obj.addComponent(HealthComponent) + obj.removeComponent(HealthComponent) + expect(obj.getComponent(HealthComponent)).toBeNull() + }) + + it('is a no-op when the component is not attached', () => { + const obj = makeGameObject() + expect(() => obj.removeComponent(HealthComponent)).not.toThrow() + }) + + it('only removes the targeted component class', () => { + const obj = makeGameObject() + obj.addComponent(HealthComponent) + const mover = obj.addComponent(MoverComponent) + obj.removeComponent(HealthComponent) + expect(obj.getComponent(MoverComponent)).toBe(mover) + }) + }) + + describe('doCreate() lifecycle', () => { + it('sets _created to true', () => { + const obj = makeGameObject() + obj.doCreate() + expect(obj._created).toBe(true) + }) + + it('calls onCreate() on all attached components', () => { + const obj = makeGameObject() + const health = obj.addComponent(HealthComponent) + const mover = obj.addComponent(MoverComponent) + obj.doCreate() + expect(health.onCreate).toHaveBeenCalledOnce() + expect(mover.onCreate).toHaveBeenCalledOnce() + }) + + it('calls the object own onCreate() before component onCreate()s', () => { + const order: string[] = [] + const obj = makeGameObject() + obj.onCreate = () => { order.push('object') } + const health = obj.addComponent(HealthComponent) + health.onCreate.mockImplementation(() => { order.push('health') }) + obj.doCreate() + expect(order).toEqual(['object', 'health']) + }) + }) + + describe('doUpdate() lifecycle', () => { + it('calls onUpdate() on all attached components', () => { + const obj = makeGameObject() + const health = obj.addComponent(HealthComponent) + const mover = obj.addComponent(MoverComponent) + obj.doUpdate(1000, 16) + expect(health.onUpdate).toHaveBeenCalledWith(1000, 16) + expect(mover.onUpdate).toHaveBeenCalledWith(1000, 16) + }) + + it('does not call onUpdate() on removed components', () => { + const obj = makeGameObject() + const health = obj.addComponent(HealthComponent) + obj.removeComponent(HealthComponent) + obj.doUpdate(1000, 16) + expect(health.onUpdate).not.toHaveBeenCalled() + }) + }) + + describe('preDestroy() lifecycle', () => { + function stubPhaserPreDestroy() { + // Phaser Container.prototype.preDestroy accesses renderer state not + // available in Node. Stub it so the GameObject path can run cleanly. + const superProto = Object.getPrototypeOf(GameObject.prototype) + const orig = superProto.preDestroy + superProto.preDestroy = vi.fn() + return () => { superProto.preDestroy = orig } + } + + it('calls onDestroy() on all attached components', () => { + const restore = stubPhaserPreDestroy() + try { + const obj = makeGameObject() + obj._effects = null + const health = obj.addComponent(HealthComponent) + const mover = obj.addComponent(MoverComponent) + obj.preDestroy() + expect(health.onDestroy).toHaveBeenCalledOnce() + expect(mover.onDestroy).toHaveBeenCalledOnce() + } finally { + restore() + } + }) + + it('clears the component map so getComponent returns null', () => { + const restore = stubPhaserPreDestroy() + try { + const obj = makeGameObject() + obj._effects = null + obj.addComponent(HealthComponent) + obj.preDestroy() + expect(obj.getComponent(HealthComponent)).toBeNull() + } finally { + restore() + } + }) + }) + +}) diff --git a/phaser-plus/test/SaveService.test.ts b/phaser-plus/test/SaveService.test.ts new file mode 100644 index 00000000..9888bd7c --- /dev/null +++ b/phaser-plus/test/SaveService.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import SaveService from '../src/persistence/SaveService' +import MemoryBackend from '../src/persistence/MemoryBackend' + +function makeService(namespace = 'test', schema = { version: 1 }) { + return new SaveService({ backend: new MemoryBackend(), namespace, schema }) +} + +describe('SaveService', () => { + let service: SaveService + + beforeEach(() => { + service = makeService() + }) + + describe('save / load', () => { + it('round-trips plain data', async () => { + await service.save('slot-1', { score: 100, level: 3 }) + const data = await service.load('slot-1') + expect(data).toEqual({ score: 100, level: 3 }) + }) + + it('returns null for a missing slot', async () => { + expect(await service.load('missing')).toBeNull() + }) + + it('overwrites an existing slot', async () => { + await service.save('slot-1', { score: 10 }) + await service.save('slot-1', { score: 99 }) + expect((await service.load<{ score: number }>('slot-1'))?.score).toBe(99) + }) + }) + + describe('delete', () => { + it('removes a saved slot', async () => { + await service.save('slot-1', { score: 1 }) + await service.delete('slot-1') + expect(await service.load('slot-1')).toBeNull() + }) + + it('does not throw when slot does not exist', async () => { + await expect(service.delete('ghost')).resolves.toBeUndefined() + }) + }) + + describe('has', () => { + it('returns true for a saved slot', async () => { + await service.save('s', {}) + expect(await service.has('s')).toBe(true) + }) + + it('returns false for a missing slot', async () => { + expect(await service.has('nope')).toBe(false) + }) + }) + + describe('list', () => { + it('returns all saved entries', async () => { + await service.save('slot-1', {}) + await service.save('slot-2', {}) + const entries = await service.list() + expect(entries.map(e => e.id).sort()).toEqual(['slot-1', 'slot-2']) + }) + + it('does not return entries from a different namespace', async () => { + const other = makeService('other') + await other.save('slot-1', {}) + const entries = await service.list() + expect(entries).toHaveLength(0) + }) + + it('includes savedAt and version in each entry', async () => { + await service.save('slot-1', {}) + const [entry] = await service.list() + expect(entry.version).toBe(1) + expect(typeof entry.savedAt).toBe('number') + expect(entry.savedAt).toBeGreaterThan(0) + }) + }) + + describe('migration', () => { + it('applies a single migration step', async () => { + const backend = new MemoryBackend() + + const v1 = new SaveService({ + backend, + namespace: 'mg', + schema: { version: 1 } + }) + await v1.save('s', { score: 42 }) + + const v2 = new SaveService({ + backend, + namespace: 'mg', + schema: { + version: 2, + migrations: { + 1: (d: unknown) => ({ ...(d as object), playerName: 'Hero' }) + } + } + }) + const loaded = await v2.load<{ score: number, playerName: string }>('s') + expect(loaded?.score).toBe(42) + expect(loaded?.playerName).toBe('Hero') + }) + + it('chains v1→v2→v3 migrations', async () => { + const backend = new MemoryBackend() + + const v1 = new SaveService({ backend, namespace: 'chain', schema: { version: 1 } }) + await v1.save('s', { a: 1 }) + + const v3 = new SaveService({ + backend, + namespace: 'chain', + schema: { + version: 3, + migrations: { + 1: (d: unknown) => ({ ...(d as object), b: 2 }), + 2: (d: unknown) => ({ ...(d as object), c: 3 }) + } + } + }) + const loaded = await v3.load<{ a: number, b: number, c: number }>('s') + expect(loaded).toEqual({ a: 1, b: 2, c: 3 }) + }) + + it('returns data unchanged when schema versions match', async () => { + const v2 = new SaveService({ + backend: new MemoryBackend(), + namespace: 'same', + schema: { version: 2, migrations: { 1: () => ({ injected: true }) } } + }) + await v2.save('s', { score: 7 }) + const loaded = await v2.load<{ score: number }>('s') + expect(loaded?.score).toBe(7) + expect((loaded as any).injected).toBeUndefined() + }) + }) + + describe('setSchema', () => { + it('updates schema in place', async () => { + const backend = new MemoryBackend() + const svc = new SaveService({ backend, namespace: 'upd', schema: { version: 1 } }) + await svc.save('s', { x: 1 }) + + svc.setSchema({ + version: 2, + migrations: { 1: (d: unknown) => ({ ...(d as object), y: 2 }) } + }) + const loaded = await svc.load<{ x: number, y: number }>('s') + expect(loaded?.y).toBe(2) + }) + }) +}) diff --git a/phaser-plus/test/ServiceRegistry.test.ts b/phaser-plus/test/ServiceRegistry.test.ts new file mode 100644 index 00000000..742f5233 --- /dev/null +++ b/phaser-plus/test/ServiceRegistry.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from 'vitest' +import ServiceRegistry from '../src/features/ServiceRegistry' + +class BasicService { + value = 42 +} + +class DisposableService { + dispose = vi.fn() +} + +describe('ServiceRegistry', () => { + describe('dispose(cls)', () => { + it('calls dispose() on a resolved disposable instance', () => { + const registry = new ServiceRegistry() + const instance = registry.resolve(DisposableService) + registry.dispose(DisposableService) + expect(instance.dispose).toHaveBeenCalledOnce() + }) + + it('removes the instance after dispose', () => { + const registry = new ServiceRegistry() + registry.resolve(DisposableService) + registry.dispose(DisposableService) + expect(registry.has(DisposableService)).toBe(false) + }) + + it('does not throw for a non-disposable service', () => { + const registry = new ServiceRegistry() + registry.resolve(BasicService) + expect(() => registry.dispose(BasicService)).not.toThrow() + }) + + it('does not throw when called on an unresolved service', () => { + const registry = new ServiceRegistry() + expect(() => registry.dispose(BasicService)).not.toThrow() + }) + + it('calls dispose() on a provided instance', () => { + const registry = new ServiceRegistry() + const instance = new DisposableService() + registry.provide(DisposableService, instance) + registry.dispose(DisposableService) + expect(instance.dispose).toHaveBeenCalledOnce() + }) + }) + + describe('disposeAll()', () => { + it('calls dispose() on every disposable instance', () => { + const registry = new ServiceRegistry() + const a = registry.resolve(DisposableService) + + class OtherDisposable { + dispose = vi.fn() + } + const b = registry.resolve(OtherDisposable) + + registry.disposeAll() + expect(a.dispose).toHaveBeenCalledOnce() + expect(b.dispose).toHaveBeenCalledOnce() + }) + + it('does not throw when mixed disposable and non-disposable services exist', () => { + const registry = new ServiceRegistry() + registry.resolve(BasicService) + registry.resolve(DisposableService) + expect(() => registry.disposeAll()).not.toThrow() + }) + + it('clears all instances and factories', () => { + const registry = new ServiceRegistry() + registry.resolve(BasicService) + registry.resolve(DisposableService) + registry.disposeAll() + expect(registry.has(BasicService)).toBe(false) + expect(registry.has(DisposableService)).toBe(false) + }) + + it('does not call dispose() on non-disposable services', () => { + const registry = new ServiceRegistry() + const instance = registry.resolve(BasicService) + expect(() => registry.disposeAll()).not.toThrow() + expect((instance as any).dispose).toBeUndefined() + }) + }) +}) diff --git a/phaser-plus/test/Structs.test.ts b/phaser-plus/test/Structs.test.ts new file mode 100644 index 00000000..dc5b9cbc --- /dev/null +++ b/phaser-plus/test/Structs.test.ts @@ -0,0 +1,580 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { SpatialHash, Quadtree, Vec2, Easing, Random, AABB, Transform } from '../src/structs' + +// ─── SpatialHash ──────────────────────────────────────────────────────────── + +describe('Structs.SpatialHash', () => { + const CS = 100 + let hash: InstanceType> + + beforeEach(() => { + hash = new SpatialHash(CS) + }) + + it('starts empty', () => { + expect(hash.size).toBe(0) + }) + + it('throws on non-positive cellSize', () => { + expect(() => new SpatialHash(0)).toThrow() + expect(() => new SpatialHash(-1)).toThrow() + }) + + describe('insert / remove', () => { + it('inserts an item and increments size', () => { + hash.insert('a', { x: 0, y: 0, width: 10, height: 10 }) + expect(hash.size).toBe(1) + }) + + it('ignores a duplicate insert', () => { + const b = { x: 0, y: 0, width: 10, height: 10 } + hash.insert('a', b) + hash.insert('a', b) + expect(hash.size).toBe(1) + }) + + it('removes an inserted item', () => { + hash.insert('a', { x: 0, y: 0, width: 10, height: 10 }) + expect(hash.remove('a')).toBe(true) + expect(hash.size).toBe(0) + }) + + it('returns false when removing an absent item', () => { + expect(hash.remove('ghost')).toBe(false) + }) + }) + + describe('update', () => { + it('moves an item to a new position', () => { + hash.insert('a', { x: 0, y: 0, width: 10, height: 10 }) + hash.update('a', { x: 500, y: 500, width: 10, height: 10 }) + const near = hash.query({ x: 0, y: 0, width: 20, height: 20 }) + expect(near).toHaveLength(0) + const far = hash.query({ x: 490, y: 490, width: 30, height: 30 }) + expect(far).toContain('a') + }) + }) + + describe('query', () => { + it('returns overlapping items', () => { + hash.insert('a', { x: 10, y: 10, width: 20, height: 20 }) + hash.insert('b', { x: 200, y: 200, width: 20, height: 20 }) + const result = hash.query({ x: 0, y: 0, width: 50, height: 50 }) + expect(result).toContain('a') + expect(result).not.toContain('b') + }) + + it('returns no duplicates when item spans multiple cells', () => { + // item spans > 1 cell (CS=100, item width=150) + hash.insert('big', { x: 50, y: 50, width: 150, height: 10 }) + const result = hash.query({ x: 0, y: 0, width: 300, height: 100 }) + expect(result.filter(x => x === 'big')).toHaveLength(1) + }) + + it('returns empty array when nothing overlaps', () => { + hash.insert('a', { x: 0, y: 0, width: 10, height: 10 }) + expect(hash.query({ x: 500, y: 500, width: 10, height: 10 })).toHaveLength(0) + }) + }) + + describe('nearest', () => { + it('finds the closest item', () => { + hash.insert('near', { x: 5, y: 5, width: 10, height: 10 }) + hash.insert('far', { x: 500, y: 500, width: 10, height: 10 }) + expect(hash.nearest({ x: 0, y: 0 })).toBe('near') + }) + + it('returns null when the hash is empty', () => { + expect(hash.nearest({ x: 0, y: 0 })).toBeNull() + }) + + it('respects maxDist', () => { + hash.insert('a', { x: 200, y: 200, width: 10, height: 10 }) + expect(hash.nearest({ x: 0, y: 0 }, 50)).toBeNull() + }) + + it('returns item at exact point (distance 0)', () => { + hash.insert('here', { x: 0, y: 0, width: 50, height: 50 }) + expect(hash.nearest({ x: 25, y: 25 })).toBe('here') + }) + }) + + describe('clear', () => { + it('removes all items', () => { + hash.insert('a', { x: 0, y: 0, width: 10, height: 10 }) + hash.insert('b', { x: 50, y: 50, width: 10, height: 10 }) + hash.clear() + expect(hash.size).toBe(0) + expect(hash.query({ x: 0, y: 0, width: 200, height: 200 })).toHaveLength(0) + }) + + it('is chainable', () => { + expect(hash.clear()).toBe(hash) + }) + }) +}) + +// ─── Quadtree ─────────────────────────────────────────────────────────────── + +describe('Structs.Quadtree', () => { + const BOUNDS = { x: 0, y: 0, width: 1000, height: 1000 } + let qt: InstanceType> + + beforeEach(() => { + qt = new Quadtree(BOUNDS) + }) + + it('starts empty', () => { + expect(qt.size).toBe(0) + }) + + it('throws when bounds is null/undefined', () => { + expect(() => new Quadtree(null as any)).toThrow() + expect(() => new Quadtree(undefined as any)).toThrow() + }) + + it('throws on invalid capacity', () => { + expect(() => new Quadtree(BOUNDS, 0)).toThrow() + expect(() => new Quadtree(BOUNDS, -1)).toThrow() + }) + + it('throws on negative maxDepth', () => { + expect(() => new Quadtree(BOUNDS, 8, -1)).toThrow() + }) + + describe('insert / remove', () => { + it('inserts an item and increments size', () => { + const ok = qt.insert('a', { x: 10, y: 10, width: 10, height: 10 }) + expect(ok).toBe(true) + expect(qt.size).toBe(1) + }) + + it('returns false for a duplicate insert', () => { + const b = { x: 10, y: 10, width: 10, height: 10 } + qt.insert('a', b) + expect(qt.insert('a', b)).toBe(false) + expect(qt.size).toBe(1) + }) + + it('returns false for an item outside the root bounds', () => { + expect(qt.insert('out', { x: 2000, y: 2000, width: 10, height: 10 })).toBe(false) + }) + + it('removes an inserted item', () => { + qt.insert('a', { x: 10, y: 10, width: 10, height: 10 }) + expect(qt.remove('a')).toBe(true) + expect(qt.size).toBe(0) + }) + + it('returns false when removing an absent item', () => { + expect(qt.remove('ghost')).toBe(false) + }) + }) + + describe('update', () => { + it('moves an item to a new position', () => { + qt.insert('a', { x: 0, y: 0, width: 10, height: 10 }) + qt.update('a', { x: 800, y: 800, width: 10, height: 10 }) + expect(qt.query({ x: 0, y: 0, width: 20, height: 20 })).not.toContain('a') + expect(qt.query({ x: 790, y: 790, width: 30, height: 30 })).toContain('a') + }) + }) + + describe('query', () => { + it('returns items within the query rect', () => { + qt.insert('a', { x: 10, y: 10, width: 20, height: 20 }) + qt.insert('b', { x: 500, y: 500, width: 20, height: 20 }) + const result = qt.query({ x: 0, y: 0, width: 50, height: 50 }) + expect(result).toContain('a') + expect(result).not.toContain('b') + }) + + it('triggers subdivision when capacity is exceeded', () => { + const small = new Quadtree(BOUNDS, 2) + for (let i = 0; i < 5; i++) { + small.insert(`item-${i}`, { x: i * 10, y: i * 10, width: 5, height: 5 }) + } + expect(small.size).toBe(5) + // all items still found in a full-bounds query + const all = small.query(BOUNDS) + expect(all).toHaveLength(5) + }) + + it('returns empty array when nothing overlaps', () => { + qt.insert('a', { x: 0, y: 0, width: 10, height: 10 }) + expect(qt.query({ x: 800, y: 800, width: 10, height: 10 })).toHaveLength(0) + }) + }) + + describe('nearest', () => { + it('finds the closest item', () => { + qt.insert('near', { x: 5, y: 5, width: 10, height: 10 }) + qt.insert('far', { x: 900, y: 900, width: 10, height: 10 }) + expect(qt.nearest({ x: 0, y: 0 })).toBe('near') + }) + + it('returns null when empty', () => { + expect(qt.nearest({ x: 0, y: 0 })).toBeNull() + }) + + it('respects maxDist', () => { + qt.insert('a', { x: 500, y: 500, width: 10, height: 10 }) + expect(qt.nearest({ x: 0, y: 0 }, 10)).toBeNull() + }) + + it('returns item when point is inside its bounds (distance 0)', () => { + qt.insert('here', { x: 0, y: 0, width: 100, height: 100 }) + expect(qt.nearest({ x: 50, y: 50 })).toBe('here') + }) + }) + + describe('clear', () => { + it('removes all items and preserves root bounds', () => { + qt.insert('a', { x: 10, y: 10, width: 10, height: 10 }) + qt.clear() + expect(qt.size).toBe(0) + expect(qt.query(BOUNDS)).toHaveLength(0) + // can still insert after clear + expect(qt.insert('b', { x: 50, y: 50, width: 10, height: 10 })).toBe(true) + }) + + it('is chainable', () => { + expect(qt.clear()).toBe(qt) + }) + }) +}) + +// ─── AABB ─────────────────────────────────────────────────────────────────── + +describe('Structs.AABB', () => { + it('constructs with defaults', () => { + const b = new AABB() + expect(b.x).toBe(0) + expect(b.y).toBe(0) + expect(b.width).toBe(0) + expect(b.height).toBe(0) + }) + + it('constructs with given values', () => { + const b = new AABB(10, 20, 30, 40) + expect(b.x).toBe(10) + expect(b.y).toBe(20) + expect(b.width).toBe(30) + expect(b.height).toBe(40) + }) + + it('computes edge and center getters', () => { + const b = new AABB(10, 20, 30, 40) + expect(b.left).toBe(10) + expect(b.right).toBe(40) + expect(b.top).toBe(20) + expect(b.bottom).toBe(60) + expect(b.centerX).toBe(25) + expect(b.centerY).toBe(40) + }) + + describe('set / reset', () => { + it('set updates all fields and is chainable', () => { + const b = new AABB() + const result = b.set(5, 6, 7, 8) + expect(result).toBe(b) + expect(b.x).toBe(5) + expect(b.width).toBe(7) + }) + + it('reset restores to zero', () => { + const b = new AABB(10, 10, 50, 50) + b.reset() + expect(b.x).toBe(0) + expect(b.y).toBe(0) + expect(b.width).toBe(0) + expect(b.height).toBe(0) + }) + }) + + describe('contains', () => { + it('returns true for a point inside', () => { + const b = new AABB(0, 0, 100, 100) + expect(b.contains(50, 50)).toBe(true) + }) + + it('returns true for a point on the edge', () => { + const b = new AABB(0, 0, 100, 100) + expect(b.contains(0, 0)).toBe(true) + expect(b.contains(100, 100)).toBe(true) + }) + + it('returns false for a point outside', () => { + const b = new AABB(0, 0, 100, 100) + expect(b.contains(101, 50)).toBe(false) + expect(b.contains(50, -1)).toBe(false) + }) + }) + + describe('intersects', () => { + it('returns true for overlapping rects', () => { + const a = new AABB(0, 0, 50, 50) + const b = new AABB(25, 25, 50, 50) + expect(a.intersects(b)).toBe(true) + }) + + it('returns true for touching edges', () => { + const a = new AABB(0, 0, 50, 50) + const b = new AABB(50, 0, 50, 50) + expect(a.intersects(b)).toBe(true) + }) + + it('returns false for non-overlapping rects', () => { + const a = new AABB(0, 0, 50, 50) + const b = new AABB(100, 100, 50, 50) + expect(a.intersects(b)).toBe(false) + }) + }) + + describe('containsRect', () => { + it('returns true when other is fully inside', () => { + const outer = new AABB(0, 0, 100, 100) + const inner = new AABB(10, 10, 80, 80) + expect(outer.containsRect(inner)).toBe(true) + }) + + it('returns false when other extends outside', () => { + const a = new AABB(0, 0, 100, 100) + const b = new AABB(50, 50, 100, 100) + expect(a.containsRect(b)).toBe(false) + }) + }) + + describe('copyFrom / clone', () => { + it('copyFrom duplicates the values', () => { + const src = new AABB(1, 2, 3, 4) + const dst = new AABB() + dst.copyFrom(src) + expect(dst.x).toBe(1) + expect(dst.width).toBe(3) + }) + + it('clone returns a new independent instance', () => { + const a = new AABB(1, 2, 3, 4) + const b = a.clone() + expect(b).not.toBe(a) + expect(b.x).toBe(1) + b.x = 99 + expect(a.x).toBe(1) + }) + }) + + describe('static factories', () => { + it('from() copies a plain rect', () => { + const b = AABB.from({ x: 5, y: 6, width: 7, height: 8 }) + expect(b).toBeInstanceOf(AABB) + expect(b.x).toBe(5) + expect(b.height).toBe(8) + }) + + it('fromCenter() positions correctly', () => { + const b = AABB.fromCenter(100, 100, 40, 20) + expect(b.x).toBe(80) + expect(b.y).toBe(90) + expect(b.width).toBe(40) + expect(b.height).toBe(20) + expect(b.centerX).toBe(100) + expect(b.centerY).toBe(100) + }) + }) +}) + +// ─── Transform ────────────────────────────────────────────────────────────── + +describe('Structs.Transform', () => { + it('initialises with identity defaults', () => { + const t = new Transform() + expect(t.x).toBe(0) + expect(t.y).toBe(0) + expect(t.rotation).toBe(0) + expect(t.scaleX).toBe(1) + expect(t.scaleY).toBe(1) + }) + + describe('set / reset', () => { + it('set updates all fields and is chainable', () => { + const t = new Transform() + const result = t.set(10, 20, Math.PI, 2, 3) + expect(result).toBe(t) + expect(t.x).toBe(10) + expect(t.y).toBe(20) + expect(t.rotation).toBe(Math.PI) + expect(t.scaleX).toBe(2) + expect(t.scaleY).toBe(3) + }) + + it('set defaults rotation/scale when omitted', () => { + const t = new Transform() + t.set(5, 7) + expect(t.rotation).toBe(0) + expect(t.scaleX).toBe(1) + expect(t.scaleY).toBe(1) + }) + + it('reset restores identity state', () => { + const t = new Transform() + t.set(99, 99, 3.14, 5, 5) + t.reset() + expect(t.x).toBe(0) + expect(t.y).toBe(0) + expect(t.rotation).toBe(0) + expect(t.scaleX).toBe(1) + expect(t.scaleY).toBe(1) + }) + }) + + describe('copyFrom / clone', () => { + it('copyFrom mirrors all fields', () => { + const src = new Transform() + src.set(3, 4, 0.5, 2, 2) + const dst = new Transform() + dst.copyFrom(src) + expect(dst.x).toBe(3) + expect(dst.rotation).toBe(0.5) + expect(dst.scaleX).toBe(2) + }) + + it('clone returns a new independent instance', () => { + const a = new Transform() + a.set(1, 2, 0.1, 3, 4) + const b = a.clone() + expect(b).not.toBe(a) + expect(b.x).toBe(1) + b.x = 99 + expect(a.x).toBe(1) + }) + }) + + describe('applyTo', () => { + it('writes all fields to a target object', () => { + const t = new Transform() + t.set(7, 8, 1.5, 2.5, 3.5) + const target = { x: 0, y: 0, rotation: 0, scaleX: 0, scaleY: 0 } + const result = t.applyTo(target) + expect(result).toBe(t) + expect(target.x).toBe(7) + expect(target.y).toBe(8) + expect(target.rotation).toBe(1.5) + expect(target.scaleX).toBe(2.5) + expect(target.scaleY).toBe(3.5) + }) + }) +}) + +// ─── Vec2 (re-export from @toolcase/base) ─────────────────────────────────── + +describe('Structs.Vec2', () => { + it('is accessible as Structs.Vec2', () => { + expect(Vec2).toBeDefined() + }) + + it('constructs with x and y', () => { + const v = new Vec2(3, 4) + expect(v.x).toBe(3) + expect(v.y).toBe(4) + }) + + it('length computes correctly', () => { + const v = new Vec2(3, 4) + expect(v.length).toBe(5) + }) + + it('add returns a new Vec2', () => { + const a = new Vec2(1, 2) + const b = new Vec2(3, 4) + const c = a.add(b) + expect(c.x).toBe(4) + expect(c.y).toBe(6) + expect(c).not.toBe(a) + }) + + it('normalize produces a unit vector', () => { + const v = new Vec2(3, 4).normalize() + expect(v.length).toBeCloseTo(1, 10) + }) + + it('ZERO and ONE singletons', () => { + expect(Vec2.ZERO.x).toBe(0) + expect(Vec2.ZERO.y).toBe(0) + expect(Vec2.ONE.x).toBe(1) + expect(Vec2.ONE.y).toBe(1) + }) +}) + +// ─── Easing (re-export from @toolcase/base) ───────────────────────────────── + +describe('Structs.Easing', () => { + it('is accessible as Structs.Easing', () => { + expect(Easing).toBeDefined() + }) + + it('easeInCubic(0) = 0 and easeInCubic(1) = 1', () => { + expect(Easing.easeInCubic(0)).toBe(0) + expect(Easing.easeInCubic(1)).toBe(1) + }) + + it('easeOutBounce(0) = 0 and easeOutBounce(1) = 1', () => { + expect(Easing.easeOutBounce(0)).toBeCloseTo(0, 10) + expect(Easing.easeOutBounce(1)).toBeCloseTo(1, 10) + }) + + it('all 30 easing families are present', () => { + const families = [ + 'easeInSine', 'easeOutSine', 'easeInOutSine', + 'easeInCubic', 'easeOutCubic', 'easeInOutCubic', + 'easeInBack', 'easeOutBack', 'easeInOutBack', + 'easeInBounce', 'easeOutBounce', 'easeInOutBounce', + 'easeInElastic', 'easeOutElastic', 'easeInOutElastic', + ] + for (const name of families) { + expect(typeof (Easing as any)[name]).toBe('function') + } + }) +}) + +// ─── Random (re-export from @toolcase/base) ────────────────────────────────── + +describe('Structs.Random', () => { + it('is accessible as Structs.Random', () => { + expect(Random).toBeDefined() + }) + + it('identical seeds produce identical sequences', () => { + const a = new Random(42) + const b = new Random(42) + for (let i = 0; i < 20; i++) { + expect(a.next()).toBe(b.next()) + } + }) + + it('next() returns values in [0, 1)', () => { + const rng = new Random(99) + for (let i = 0; i < 100; i++) { + const v = rng.next() + expect(v).toBeGreaterThanOrEqual(0) + expect(v).toBeLessThan(1) + } + }) + + it('int() returns inclusive integers in range', () => { + const rng = new Random(7) + for (let i = 0; i < 200; i++) { + const v = rng.int(1, 6) + expect(v).toBeGreaterThanOrEqual(1) + expect(v).toBeLessThanOrEqual(6) + } + }) + + it('different seeds produce different sequences', () => { + const a = new Random(1) + const b = new Random(2) + const seqA = Array.from({ length: 10 }, () => a.next()) + const seqB = Array.from({ length: 10 }, () => b.next()) + expect(seqA).not.toEqual(seqB) + }) +}) diff --git a/react-components/README.md b/react-components/README.md deleted file mode 100644 index 35faf945..00000000 --- a/react-components/README.md +++ /dev/null @@ -1,162 +0,0 @@ -# @toolcase/react-components - -[![GitHub](https://img.shields.io/github/license/kalevski/toolcase?style=for-the-badge)](https://github.com/kalevski/toolcase/blob/main/LICENSE) -[![npm version](https://img.shields.io/npm/v/@toolcase/react-components?color=teal&label=VERSION&style=for-the-badge)](https://www.npmjs.com/package/@toolcase/react-components) - -🧩 A production-grade React component library — **layout, forms, data display, feedback, file handling, and advanced editors** — built on top of Bootstrap 5 and `@toolcase/base`. TypeScript-first, BEM-themed, accessibility-friendly, no `border-radius` clutter. - -📖 Live demos & API docs: **[toolcase.kalevski.dev/react-components](https://toolcase.kalevski.dev/react-components)** - -## Install - -```bash -npm install @toolcase/react-components @toolcase/base -``` - -### Peer dependencies - -- `react >= 18` -- `react-dom >= 18` -- `@toolcase/base ^3.x` - -## Setup - -Import the bundled stylesheet **once** (e.g. in your app entrypoint): - -```ts -import '@toolcase/react-components/style.css' -``` - -That's it — components are tree-shaken, so you only pay for what you import. - -## Quick example - -```tsx -import { - DashboardLayout, Card, Heading, Text, - Button, Input, Form, FormInput, Alert -} from '@toolcase/react-components' - -export default function App() { - return ( - - - Welcome back - Sign in to continue. -
    console.log(values)}> - - - - - First time here? Use the magic link. -
    -
    - ) -} -``` - -## Components - -### Layout - -| Component | Description | -|-----------|-------------| -| `BasicLayout` | Page shell with header / content / footer slots. | -| `DashboardLayout` | Sidebar + content layout for admin UIs. | -| `DashboardCard` | Card container for dashboard widgets. | -| `SideNav` | Sidebar navigation. | -| `CoolNav` | Stylized navigation bar. | -| `Spacer`, `Divider`, `Group` | Layout primitives. | - -### Form controls - -| Component | Description | -|-----------|-------------| -| `Button`, `CoolButton`, `IconButton` | Buttons with variants. | -| `Input`, `Textarea` | Text inputs. | -| `Select`, `ExtendedSelect` | Standard + searchable selects. | -| `Checkbox`, `CheckboxGroup`, `Radio`, `RadioGroup`, `Switch`, `ToggleCard` | Toggleable controls. | -| `TagInput` | Add/remove tag tokens. | -| `ColorPicker`, `DatePicker`, `IconPicker` | Specialised pickers. | -| `Form`, `FormInput`, `FormWizard` | Form wrapper + integrated, validated inputs + multi-step wizard. | -| `EarlySignupForm`, `Login` | Pre-built composite forms. | - -### Data display - -| Component | Description | -|-----------|-------------| -| `Table` | Data table. | -| `Card`, `CardOptions`, `MultiCardSelect`, `SingleCardSelect`, `PricingCard` | Card variants. | -| `Badge`, `Tag`, `Chip`, `StatusDot` | Compact labels and status indicators. | -| `Avatar`, `Icon` | Identity + iconography. | -| `ProgressBar`, `Timeline`, `Changelog`, `UsageSummaryPanel` | Progress + history surfaces. | - -### Text & typography - -`Heading`, `Text`, `Label`, `Link`, `Kbd`, `CodeSnippet`, `HelperText`. - -### Feedback - -| Component | Description | -|-----------|-------------| -| `Alert` | Banner-style alert. | -| `Spinner`, `Skeleton` | Loading states. | -| `Tooltip` | Hover tooltip. | -| `EmptyState` | Empty placeholder. | -| `Modal` | Modal system — `Modal.Window`, `Modal.Control`, `Modal.Context`. | - -### File handling - -`FileDropzone`, `File`, `SimpleFile`, `QueuedFile`, `FileTags`, `AssetBundle`. - -### Navigation & branding - -`Brand`, `Hero`, `PageFooter`, `PinnedFeatureShowcase`, `UserPanel`, `Dropdown`, `TabSections`, `WelcomeGuide`. - -### Advanced editors / surfaces - -`JSONEditor`, `JSONSchemaDef`, `NodeEditor`, `BitmapFontGenerator`, `ActionHeader`, `ActionItems`, `Build`, `DangerZoneActions`, `VerticalItemList`, `EditableText`, `Image`, `VisuallyHidden`. - -## Theming - -### CSS custom properties - -Every component exposes a fixed set of CSS variables. Override at any DOM scope to retheme. - -```css -:root { - --rc-dropdown-bg: #1f1f1f; - --rc-dropdown-fg: #f5f5f5; - --at-row-hover-bg: #2a2a2a; -} -``` - -Per-component variable prefixes are stable (e.g. `--rc-dropdown-`, `--at-` for `AdvancedTable`). Don't invent new prefixes — it breaks future overrides. - -### SCSS overrides - -If you build your own bundle, override SCSS variables before importing: - -```scss -$primary: #5b8def; -@import '@toolcase/react-components/style/index.scss'; -``` - -### BEM naming - -Roots carry both `component` and `component-{name}`; children use `component-{name}__{part}`; modifier classes are `--{state}`. So `.component-dropdown__menu--open` is the open menu of a dropdown — easy to target without specificity wars. - -## Design constraints - -- **No `border-radius`** anywhere except intentionally circular shapes (spinner rings, slider thumbs, carousel dots). Stay sharp. -- **Mobile-first** SCSS — only `min-width` media queries. -- **Touch-friendly** — 44px minimum touch target under `@media (pointer: coarse)`. -- **Z-index scale is fixed**: `Tooltip 1070 > Dropdown 1060 > Modal content 1055 > Modal backdrop 1050`. Don't add new layers. - -## Browser support - -Modern evergreen browsers (Chrome, Edge, Firefox, Safari). React 18+ required. - -## License - -[MIT](https://github.com/kalevski/toolcase/blob/main/LICENSE) diff --git a/react-components/package.json b/react-components/package.json deleted file mode 100644 index 0b5df325..00000000 --- a/react-components/package.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "name": "@toolcase/react-components", - "version": "3.0.6", - "description": "Practical React UI components — forms, dropzone, layout, and dashboard pieces — styled and ready to drop in.", - "main": "lib/index.main.js", - "module": "lib/index.module.js", - "types": "lib/index.d.ts", - "exports": { - ".": { - "types": "./lib/index.d.ts", - "import": "./lib/index.module.js", - "require": "./lib/index.main.js" - }, - "./style.css": "./lib/index.css" - }, - "sideEffects": [ - "*.css" - ], - "scripts": { - "dev": "tsup --watch", - "build": "tsup && sass style/index.scss:lib/index.css --no-source-map --style=compressed --load-path=../node_modules --quiet-deps --silence-deprecation=import", - "build:css": "sass style/index.scss:lib/index.css --no-source-map --style=compressed --load-path=../node_modules --quiet-deps --silence-deprecation=import" - }, - "peerDependencies": { - "@toolcase/base": "3.x.x", - "react": ">=18", - "react-dom": ">=18" - }, - "dependencies": { - "dropzone": "^6.0.0-beta.2" - }, - "author": { - "name": "Daniel Kalevski", - "url": "https://kalevski.dev" - }, - "homepage": "https://toolcase.kalevski.dev", - "keywords": [ - "toolcase", - "react", - "react-components", - "ui", - "components", - "form", - "dropzone", - "upload", - "design-system", - "typescript", - "frontend" - ], - "directories": { - "lib": "lib" - }, - "files": [ - "lib" - ], - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/kalevski/toolcase.git", - "directory": "react-components" - }, - "bugs": { - "url": "https://github.com/kalevski/toolcase/issues" - }, - "engines": { - "node": ">=18" - }, - "license": "MIT" -} diff --git a/react-components/src/Accordion.tsx b/react-components/src/Accordion.tsx deleted file mode 100644 index e3a943bf..00000000 --- a/react-components/src/Accordion.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useId, useState } from 'react' -import { Icon } from './Icon' - -// ── Types ────────────────────────────────────────────────────────────────────── - -export interface AccordionItem { - key: string - title: React.ReactNode - content: React.ReactNode - disabled?: boolean -} - -export interface AccordionProps extends React.HTMLAttributes { - items: AccordionItem[] - /** - * Allow multiple panels to be open simultaneously. - * Default: false (single-open mode). - */ - multiple?: boolean - /** Keys of initially-open panels (uncontrolled). */ - defaultOpen?: string[] - /** Controlled open keys. Requires `onOpenChange`. */ - open?: string[] - onOpenChange?: (keys: string[]) => void - variant?: 'bordered' | 'borderless' - className?: string -} - -export const Accordion: React.FC = ({ - items, - multiple = false, - defaultOpen = [], - open: openProp, - onOpenChange, - variant = 'bordered', - className = '', - ...rest -}) => { - const isControlled = openProp !== undefined - const [internalOpen, setInternalOpen] = useState(defaultOpen) - const openKeys = isControlled ? openProp! : internalOpen - const baseId = useId() - - const toggle = (key: string) => { - let next: string[] - if (openKeys.includes(key)) { - next = openKeys.filter((k) => k !== key) - } else { - next = multiple ? [...openKeys, key] : [key] - } - if (!isControlled) setInternalOpen(next) - onOpenChange?.(next) - } - - const rootClass = [ - 'component component-accordion', - `component-accordion--${variant}`, - className, - ].filter(Boolean).join(' ') - - return ( -
    - {items.map((item) => { - const isOpen = openKeys.includes(item.key) - const headingId = `${baseId}-heading-${item.key}` - const panelId = `${baseId}-panel-${item.key}` - - return ( -
    -

    - -

    -
    -
    - {item.content} -
    -
    -
    - ) - })} -
    - ) -} - -// expose a helper ref for programmatic control -export type AccordionRef = { - openAll: () => void - closeAll: () => void -} diff --git a/react-components/src/ActionHeader.tsx b/react-components/src/ActionHeader.tsx deleted file mode 100644 index 213eca6e..00000000 --- a/react-components/src/ActionHeader.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react' -import { Button } from './Button' -import { Icon } from './Icon' - -export interface ActionHeaderAction { - key: string - icon?: string - label: string - alt?: string - disabled?: boolean -} - -export interface ActionHeaderProps { - actions: ActionHeaderAction[] - onExec?: (key: string) => void - disabled?: boolean - className?: string - children?: React.ReactNode -} - -export const ActionHeader: React.FC = ({ - actions, - onExec, - disabled = false, - className = '', - children, -}) => { - return ( -
    - {children &&
    {children}
    } -
    - {actions.map(action => ( - - ))} -
    -
    - ) -} diff --git a/react-components/src/ActionItems.tsx b/react-components/src/ActionItems.tsx deleted file mode 100644 index c6294a2f..00000000 --- a/react-components/src/ActionItems.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' -import { Icon } from './Icon' - -export interface ActionItem { - key: string - icon?: string - label: string -} - -export interface ActionItemsProps { - items: ActionItem[] - onActionClick?: (key: string) => void - /** Accessible name for the trigger button. */ - label?: string -} - -export const ActionItems: React.FC = ({ - items, - onActionClick, - label = 'Actions', -}) => { - const [open, setOpen] = useState(false) - const [activeIndex, setActiveIndex] = useState(-1) - const [pos, setPos] = useState<{ top: number; left: number } | null>(null) - const triggerRef = useRef(null) - const menuRef = useRef(null) - const itemRefs = useRef>([]) - - // Anchor the fixed-position menu to the trigger's bottom-right. - const updatePosition = useCallback(() => { - const t = triggerRef.current - if (!t) return - const r = t.getBoundingClientRect() - setPos({ top: r.bottom + 4, left: r.right }) - }, []) - - // Reposition while open (scroll on any ancestor, or resize). - useEffect(() => { - if (!open) return - updatePosition() - const handler = () => updatePosition() - window.addEventListener('scroll', handler, true) - window.addEventListener('resize', handler) - return () => { - window.removeEventListener('scroll', handler, true) - window.removeEventListener('resize', handler) - } - }, [open, updatePosition]) - - // Close on outside click — the menu lives in a portal, so check both nodes. - useEffect(() => { - if (!open) return - const handler = (e: MouseEvent) => { - const node = e.target as Node - if (triggerRef.current?.contains(node)) return - if (menuRef.current?.contains(node)) return - setOpen(false) - setActiveIndex(-1) - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [open]) - - // Move DOM focus to the active menu item (roving tabindex). - useEffect(() => { - if (open && activeIndex >= 0) { - itemRefs.current[activeIndex]?.focus() - } - }, [open, activeIndex]) - - if (items.length === 0) return null - - const openMenu = (index: number) => { - updatePosition() - setOpen(true) - setActiveIndex(index) - } - - const closeMenu = (refocus = true) => { - setOpen(false) - setActiveIndex(-1) - if (refocus) triggerRef.current?.focus() - } - - const handleSelect = (key: string) => { - onActionClick?.(key) - closeMenu() - } - - const handleTriggerKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - openMenu(0) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - openMenu(items.length - 1) - } - } - - const handleItemKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault() - setActiveIndex((i) => (i + 1) % items.length) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setActiveIndex((i) => (i - 1 + items.length) % items.length) - } else if (e.key === 'Home') { - e.preventDefault() - setActiveIndex(0) - } else if (e.key === 'End') { - e.preventDefault() - setActiveIndex(items.length - 1) - } else if (e.key === 'Escape') { - e.preventDefault() - closeMenu() - } else if (e.key === 'Tab') { - closeMenu(false) - } - } - - const menu = open && pos && ( -
      - {items.map((item, index) => ( -
    • - -
    • - ))} -
    - ) - - return ( -
    - - {typeof document !== 'undefined' && menu ? createPortal(menu, document.body) : null} -
    - ) -} diff --git a/react-components/src/ActionRowList.tsx b/react-components/src/ActionRowList.tsx deleted file mode 100644 index 99b54aa9..00000000 --- a/react-components/src/ActionRowList.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react' -import { Icon } from './Icon' -import { Button } from './Button' - -export type ActionRowButtonVariant = - | 'primary' - | 'secondary' - | 'success' - | 'warning' - | 'info' - | 'danger' - -export interface ActionRow { - key: string - icon: string - title: string - description: string - buttonText: string - buttonVariant?: ActionRowButtonVariant - disabled?: boolean -} - -export interface ActionRowListProps { - actions: ActionRow[] - onActionClick: (key: string) => void - outline?: boolean - trailingIcon?: string | null - className?: string -} - -export const ActionRowList: React.FC = ({ - actions, - onActionClick, - outline = true, - trailingIcon = 'arrow-right', - className, -}) => { - const rootClass = `component component-action-row-list ${className || ''}`.trim() - return ( -
      - {actions.map((action, i) => { - const isLast = i === actions.length - 1 - const rowClass = `component-action-row-list__row${isLast ? ' component-action-row-list__row--last' : ''}` - return ( -
    • -
      - - - -
      -
      {action.title}
      -
      - {action.description} -
      -
      -
      -
      - -
      -
    • - ) - })} -
    - ) -} diff --git a/react-components/src/AdvancedTable.tsx b/react-components/src/AdvancedTable.tsx deleted file mode 100644 index ece5a5c5..00000000 --- a/react-components/src/AdvancedTable.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Table } from './Table' -import type { TableProps, TableColumn } from './Table' -import { Pagination } from './Pagination' -import { FormInput } from './FormInput' -import type { FormInputType, FormInputProps } from './FormInput' -import { Spinner } from './Spinner' -import { Icon } from './Icon' -import { Button } from './Button' - -export interface AdvancedTableFilter extends Omit { - key: string - type: FormInputType -} - -export interface AdvancedTableSort { - key: string - direction: 'asc' | 'desc' -} - -export interface AdvancedTableProps extends Omit, 'className'> { - filters?: AdvancedTableFilter[] - filterValues?: Record - onFilterChange?: (key: string, value: any) => void - - sortableColumns?: string[] - sort?: AdvancedTableSort | null - onSortChange?: (sort: AdvancedTableSort | null) => void - - limit?: number - offset?: number - total?: number - onOffsetChange?: (offset: number) => void - - loading?: boolean - - className?: string -} - -export function AdvancedTable({ - filters = [], - filterValues = {}, - onFilterChange, - - sortableColumns = [], - sort, - onSortChange, - - limit, - offset, - total, - onOffsetChange, - - loading = false, - - columns, - className = '', - ...tableProps -}: AdvancedTableProps) { - const rootClass = [ - 'component component-advanced-table', - loading ? 'component-advanced-table--loading' : '', - className, - ].filter(Boolean).join(' ') - - const enrichedColumns: TableColumn[] = columns.map((col) => { - if (!sortableColumns.includes(col.key)) return col - - const isActive = sort?.key === col.key - const direction = isActive ? sort!.direction : null - - const handleSort = () => { - if (loading || !onSortChange) return - if (!isActive) return onSortChange({ key: col.key, direction: 'asc' }) - if (direction === 'asc') return onSortChange({ key: col.key, direction: 'desc' }) - onSortChange(null) - } - - return { - ...col, - ariaSort: isActive ? (direction === 'asc' ? 'ascending' : 'descending') : 'none', - header: ( - - ), - } - }) - - const hasPagination = limit !== undefined && offset !== undefined && total !== undefined && total > 0 - - return ( -
    - {filters.length > 0 && ( -
    - {filters.map(({ key, ...filterProps }) => ( -
    - onFilterChange?.(key, v)} - /> -
    - ))} - {onFilterChange && Object.values(filterValues).some((v) => v !== '' && v !== null && v !== undefined) && ( -
    - -
    - )} -
    - )} - -
    - {loading && ( -
    - -
    - )} - - - - {hasPagination && ( - - )} - - ) -} diff --git a/react-components/src/Alert.tsx b/react-components/src/Alert.tsx deleted file mode 100644 index 86d40384..00000000 --- a/react-components/src/Alert.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react' -import { Icon } from './Icon' -import { IconButton } from './IconButton' -import { Skeleton } from './Skeleton' - -export interface AlertProps { - children?: React.ReactNode - message?: string - title?: string - icon?: React.ReactNode - variant?: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'danger' - dismissible?: boolean - onClose?: () => void - className?: string - loading?: boolean -} - -export const Alert: React.FC = ({ - children, - message, - title, - icon, - variant = 'primary', - dismissible = false, - onClose, - className = '', - loading = false, -}) => { - const alertClass = `component component-alert alert alert-${variant} d-flex align-items-center justify-content-between ${className}`.trim() - - if (loading) { - return ( -
    -
    - -
    -
    - ) - } - - return ( -
    -
    - {icon && - - } -
    - {title &&
    {title}
    } - {message} - {children} -
    -
    - {dismissible && } -
    - ) -} diff --git a/react-components/src/AnnouncementBar.tsx b/react-components/src/AnnouncementBar.tsx deleted file mode 100644 index 45d72d9b..00000000 --- a/react-components/src/AnnouncementBar.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { Icon } from './Icon' - -export type AnnouncementBarVariant = 'info' | 'success' | 'warning' | 'announce' - -export interface AnnouncementBarProps extends Omit, 'children'> { - message: React.ReactNode - ctaLabel?: string - ctaHref?: string - dismissible?: boolean - variant?: AnnouncementBarVariant - persistDismissKey?: string - icon?: React.ReactNode - iconName?: string - onDismiss?: () => void -} - -export const AnnouncementBar: React.FC = ({ - message, - ctaLabel, - ctaHref, - dismissible = false, - variant = 'info', - persistDismissKey, - icon, - iconName, - onDismiss, - className = '', - ...rest -}) => { - const [dismissed, setDismissed] = useState(false) - - useEffect(() => { - if (persistDismissKey && typeof window !== 'undefined') { - try { - if (window.localStorage.getItem(`tc-announce:${persistDismissKey}`) === '1') { - setDismissed(true) - } - } catch { - /* ignore */ - } - } - }, [persistDismissKey]) - - const handleDismiss = () => { - setDismissed(true) - if (persistDismissKey && typeof window !== 'undefined') { - try { - window.localStorage.setItem(`tc-announce:${persistDismissKey}`, '1') - } catch { - /* ignore */ - } - } - onDismiss?.() - } - - if (dismissed) return null - - const rootClass = `component component-announcement-bar component-announcement-bar--${variant} ${className}`.trim() - - return ( -
    -
    - {(icon || iconName) && ( - - )} -
    {message}
    - {ctaLabel && ctaHref && ( - - {ctaLabel} - - - )} - {dismissible && ( - - )} -
    -
    - ) -} - -export default AnnouncementBar diff --git a/react-components/src/ApiReferenceTable.tsx b/react-components/src/ApiReferenceTable.tsx deleted file mode 100644 index 2b827226..00000000 --- a/react-components/src/ApiReferenceTable.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react' -import { Icon } from './Icon' - -export interface ApiItem { - name: string - signature: string - returns?: string - description?: React.ReactNode - since?: string - deprecated?: boolean - href?: string -} - -export interface ApiReferenceGroup { - title: string - items: ApiItem[] -} - -export interface ApiReferenceTableProps extends Omit, 'title'> { - groups?: ApiReferenceGroup[] - items?: ApiItem[] - title?: React.ReactNode -} - -const Row: React.FC<{ item: ApiItem }> = ({ item }) => ( -
    -
    - - {item.href ? {item.name} : item.name} - -
    - {item.since ? ( - since {item.since} - ) : null} - {item.deprecated ? ( - - deprecated - - ) : null} -
    -
    -
    - {item.signature} - {item.returns ? ( - - returns - {item.returns} - - ) : null} -
    - {item.description ? ( -
    {item.description}
    - ) : null} -
    -) - -export const ApiReferenceTable: React.FC = ({ - groups, - items, - title, - className = '', - ...rest -}) => { - const rootClass = `component component-api-reference-table ${className}`.trim() - - return ( -
    - {title ?

    {title}

    : null} - {groups - ? groups.map((g) => ( -
    -

    {g.title}

    -
    - {g.items.map((item) => ( - - ))} -
    -
    - )) - : null} - {items ? ( -
    - {items.map((item) => ( - - ))} -
    - ) : null} -
    - ) -} - -export default ApiReferenceTable diff --git a/react-components/src/AssetBundle.tsx b/react-components/src/AssetBundle.tsx deleted file mode 100644 index bd8c1809..00000000 --- a/react-components/src/AssetBundle.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import React from 'react' -import { Tag } from './Tag' -import { Badge } from './Badge' -import { Switch } from './Switch' -import { Icon } from './Icon' -import { ActionItems, ActionItem } from './ActionItems' -import { Skeleton } from './Skeleton' -import { ProgressBar } from './ProgressBar' - -export type AssetBundleEngine = 'unity' | 'godot' | 'unreal' | 'phaser' | 'pixijs' | 'custom' // Unreal - -export interface AssetBundleAdvancedOptions { - /** Scale percentage (1–100) */ - scale?: number - /** Whether rotation is enabled for packing */ - rotationEnabled?: boolean - /** Packing algorithm — available set depends on target engine */ - algorithm?: string -} - -export interface AssetBundleProps { - /** Display name of the asset bundle */ - name: string - /** Target game engine */ - target: string - /** Override icon for the target engine (Bootstrap Icon name) */ - targetIcon?: string - /** Selected category — when undefined all categories are included */ - category?: string - /** Tags that are included in this bundle */ - includedTags?: string[] - /** Tags that are excluded from this bundle */ - excludedTags?: string[] - /** The single default build tag */ - defaultBuildTag?: string - /** Number of included files by type — keys are dynamic (e.g. "textures", "sounds") */ - counts?: Record - /** Latest build reference (e.g. commit hash, build number) */ - latestBuildRef?: string - /** Build tag associated with the latest build */ - buildTag?: string - /** Advanced packing options */ - advanced?: AssetBundleAdvancedOptions - /** Action menu items */ - menuItems?: ActionItem[] - /** Callback when an action menu item is clicked */ - onMenuItemClick?: (key: string) => void - /** Additional class name */ - className?: string - - onBuildClick?: () => void - loading?: boolean -} - -const fileTypeIcons: Record = { - textures: 'image', - sounds: 'music-note-beamed', - fonts: 'fonts', - configs: 'file-earmark-code', - dialogues: 'chat-left-text', - animations: 'film', - models: 'box', - scripts: 'file-earmark-binary', - shaders: 'lightning', - materials: 'palette', - prefabs: 'layers', - scenes: 'grid-3x3', - maps: 'map', - data: 'database', -} - -function fileTypeIcon(type: string): string { - return fileTypeIcons[type.toLowerCase()] ?? 'file-earmark' -} - -export const AssetBundle: React.FC = ({ - name, - target, - targetIcon, - category, - includedTags = [], - excludedTags = [], - defaultBuildTag, - counts = {}, - latestBuildRef, - buildTag, - advanced = {}, - menuItems = [], - onMenuItemClick, - onBuildClick, - className = '', - loading = false, -}) => { - const { scale = 100, rotationEnabled = false, algorithm } = advanced - const fileEntries = Object.entries(counts) - const totalFiles = fileEntries.reduce((sum, [, n]) => sum + n, 0) - - const rootClass = [ - 'component component-asset-bundle', - className, - ].filter(Boolean).join(' ') - - if (loading) { - return ( -
    -
    - -
    - - -
    -
    -
    -
    - -
    -
    -
    - -
    -
    - ) - } - - return ( -
    - {/* ── Header ── */} -
    -
    - -
    -
    - {name} - {target} -
    - {menuItems.length > 0 && ( - - )} -
    - - {/* ── Divider ── */} -
    - - {/* ── Tags section ── */} -
    - {/* Category */} -
    - - - Category - - {category ? ( - {category} - ) : ( - All Categories - )} -
    - - {/* Default build tag */} - {defaultBuildTag && ( -
    - - - Build Tag - - {defaultBuildTag} -
    - )} - -
    - - - Included - -
    - {includedTags.map((t) => ( - {t} - ))} -
    -
    - -
    - - - Excluded - -
    - {excludedTags.map((t) => ( - {t} - ))} -
    -
    - -
    - - - Build Ref - - - {latestBuildRef} - {buildTag && {buildTag}} - -
    -
    - - {/* ── File counts ── */} - {fileEntries.length > 0 && ( - <> -
    -
    - - - Files - {totalFiles} - -
    - {fileEntries.map(([type, count]) => ( - - {type} {count} - - ))} -
    -
    - - )} - - {/* ── Advanced options ── */} -
    -
    - - - Advanced - - -
    - {/* Scale */} - - - {/* Rotation */} -
    - Rotation - -
    - - {/* Algorithm */} - {algorithm && ( -
    - Algorithm - {algorithm} -
    - )} -
    -
    -
    - ) -} diff --git a/react-components/src/AssetRow.tsx b/react-components/src/AssetRow.tsx deleted file mode 100644 index 8e92105f..00000000 --- a/react-components/src/AssetRow.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { FC, HTMLAttributes, ReactNode } from 'react' - -export interface AssetRowProps extends HTMLAttributes { - icon?: ReactNode - name: ReactNode - tags?: string[] - size?: ReactNode -} - -export const AssetRow: FC = ({ icon, name, tags, size, className, ...rest }) => { - const rootClassName = ['component', 'component-asset-row', className].filter(Boolean).join(' ') - return ( -
    - {icon && } - {name} - {tags && tags.length > 0 && ( - - {tags.map((tag) => ( - - {tag} - - ))} - - )} - {size !== undefined && {size}} -
    - ) -} - -AssetRow.displayName = 'AssetRow' - -export interface AssetRowListProps extends HTMLAttributes { - children: ReactNode -} - -export const AssetRowList: FC = ({ children, className, ...rest }) => { - const rootClassName = ['component', 'component-asset-row-list', className].filter(Boolean).join(' ') - return ( -
    - {children} -
    - ) -} - -AssetRowList.displayName = 'AssetRowList' diff --git a/react-components/src/AudioMixer.tsx b/react-components/src/AudioMixer.tsx deleted file mode 100644 index 6634105e..00000000 --- a/react-components/src/AudioMixer.tsx +++ /dev/null @@ -1,739 +0,0 @@ -import React, { useState, useCallback, useRef, useMemo, useId } from 'react' -import { Icon } from './Icon' -import { Button } from './Button' -import { Skeleton } from './Skeleton' - -export type AudioEffectType = 'gain' | 'eq' | 'reverb' | 'delay' - -export interface AudioEffect { - id: string - type: AudioEffectType - bypass: boolean - params: Record -} - -export interface LoopRegion { - start_ms: number - end_ms: number - enabled: boolean -} - -export interface MixerMaster { - gain_db: number - loop: LoopRegion - effects: AudioEffect[] -} - -export interface MixerClip { - id: string - asset_id: string - start_ms: number - source_in_ms: number - source_out_ms: number - gain_db: number - fade_in_ms: number - fade_out_ms: number -} - -export interface MixerTrack { - id: string - name: string - gain_db: number - pan: number - mute: boolean - solo: boolean - effects: AudioEffect[] - clips: MixerClip[] -} - -export interface AudioMixerDocument { - schema: number - sampleRate: number - duration_ms: number - master: MixerMaster - tracks: MixerTrack[] -} - -export type EffectTarget = { scope: 'master' } | { scope: 'track'; trackId: string } - -export type MixerSelection = - | { kind: 'none' } - | { kind: 'master' } - | { kind: 'track'; trackId: string } - | { kind: 'clip'; trackId: string; clipId: string } - -export const EFFECT_TYPES: AudioEffectType[] = ['gain', 'eq', 'reverb', 'delay'] - -export const EFFECT_DEFAULTS: Record> = { - gain: { gain_db: 0 }, - eq: { low_db: 0, mid_db: 0, high_db: 0 }, - reverb: { mix: 0.2, decay_s: 1.8 }, - delay: { time_ms: 250, feedback: 0.3, mix: 0.2 }, -} - -const EMPTY_DOCUMENT: AudioMixerDocument = { - schema: 1, - sampleRate: 48000, - duration_ms: 0, - master: { gain_db: 0, loop: { start_ms: 0, end_ms: 0, enabled: false }, effects: [] }, - tracks: [], -} - -const EMPTY_DOC_JSON = JSON.stringify(EMPTY_DOCUMENT) - -const TRACK_H = 76 -const RULER_H = 28 - -export const parseDocument = (raw: string): AudioMixerDocument => { - try { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && Array.isArray(parsed.tracks) && parsed.master) { - return parsed as AudioMixerDocument - } - } catch { - /* ignore */ - } - return EMPTY_DOCUMENT -} - -export const clipDuration = (clip: MixerClip): number => Math.max(0, clip.source_out_ms - clip.source_in_ms) - -export const documentDuration = (doc: AudioMixerDocument): number => { - let end = doc.duration_ms - for (const track of doc.tracks) { - for (const clip of track.clips) { - end = Math.max(end, clip.start_ms + clipDuration(clip)) - } - } - if (doc.master.loop.enabled) end = Math.max(end, doc.master.loop.end_ms) - return end -} - -export const formatTime = (ms: number): string => { - const total = Math.max(0, Math.round(ms)) - const m = Math.floor(total / 60000) - const s = Math.floor((total % 60000) / 1000) - const cs = Math.floor((total % 1000) / 10) - return `${m}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}` -} - -/* ---------------------------------------------------------------- * - * Headless hook — owns the project document + selection + mutations * - * ---------------------------------------------------------------- */ - -export interface UseAudioMixerOptions { - value?: string - defaultValue?: string - onChange?: (value: string) => void - disabled?: boolean -} - -export interface AudioMixerActions { - addTrack: (partial?: Partial) => string - removeTrack: (trackId: string) => void - updateTrack: (trackId: string, patch: Partial) => void - moveTrack: (trackId: string, dir: -1 | 1) => void - addClip: (trackId: string, clip: Omit & { id?: string }) => string - removeClip: (trackId: string, clipId: string) => void - updateClip: (trackId: string, clipId: string, patch: Partial) => void - splitClip: (trackId: string, clipId: string, atMs: number) => void - setMasterGain: (gainDb: number) => void - setLoop: (patch: Partial) => void - addEffect: (target: EffectTarget, type: AudioEffectType) => void - removeEffect: (target: EffectTarget, effectId: string) => void - updateEffect: (target: EffectTarget, effectId: string, patch: Partial>) => void -} - -export interface AudioMixerSelectionState { - current: MixerSelection - select: (selection: MixerSelection) => void - track: MixerTrack | null - clip: MixerClip | null -} - -export interface AudioMixerViewModel { - doc: AudioMixerDocument - actions: AudioMixerActions - selection: AudioMixerSelectionState - disabled: boolean -} - -export interface UseAudioMixerResult { - doc: AudioMixerDocument - actions: AudioMixerActions - selection: AudioMixerSelectionState - view: AudioMixerViewModel -} - -const targetEffects = (doc: AudioMixerDocument, target: EffectTarget): AudioEffect[] => { - if (target.scope === 'master') return doc.master.effects - return doc.tracks.find(t => t.id === target.trackId)?.effects ?? [] -} - -export const useAudioMixer = (opts: UseAudioMixerOptions = {}): UseAudioMixerResult => { - const { value, defaultValue = EMPTY_DOC_JSON, onChange, disabled = false } = opts - const isControlled = value !== undefined - const [internal, setInternal] = useState(defaultValue) - const raw = isControlled ? value : internal - const doc = useMemo(() => parseDocument(raw), [raw]) - - const idSeed = useRef(0) - const genId = useCallback((prefix: string) => { - idSeed.current += 1 - return `${prefix}_${idSeed.current.toString(36)}${Math.floor(performance.now()).toString(36)}` - }, []) - - const [selection, setSelection] = useState({ kind: 'none' }) - - const emit = useCallback( - (next: AudioMixerDocument) => { - const withDuration = { ...next, duration_ms: documentDuration(next) } - const serialized = JSON.stringify(withDuration) - if (!isControlled) setInternal(serialized) - onChange?.(serialized) - }, - [isControlled, onChange], - ) - - const mapTrack = useCallback( - (trackId: string, fn: (track: MixerTrack) => MixerTrack) => doc.tracks.map(t => (t.id === trackId ? fn(t) : t)), - [doc.tracks], - ) - - const actions = useMemo(() => { - const addTrack: AudioMixerActions['addTrack'] = partial => { - const id = genId('trk') - const track: MixerTrack = { - id, - name: `Track ${doc.tracks.length + 1}`, - gain_db: 0, - pan: 0, - mute: false, - solo: false, - effects: [], - clips: [], - ...partial, - } - emit({ ...doc, tracks: [...doc.tracks, track] }) - setSelection({ kind: 'track', trackId: id }) - return id - } - - const removeTrack: AudioMixerActions['removeTrack'] = trackId => { - emit({ ...doc, tracks: doc.tracks.filter(t => t.id !== trackId) }) - setSelection(prev => (prev.kind !== 'none' && 'trackId' in prev && prev.trackId === trackId ? { kind: 'none' } : prev)) - } - - const updateTrack: AudioMixerActions['updateTrack'] = (trackId, patch) => { - emit({ ...doc, tracks: mapTrack(trackId, t => ({ ...t, ...patch })) }) - } - - const moveTrack: AudioMixerActions['moveTrack'] = (trackId, dir) => { - const idx = doc.tracks.findIndex(t => t.id === trackId) - const next = idx + dir - if (idx < 0 || next < 0 || next >= doc.tracks.length) return - const tracks = [...doc.tracks] - ;[tracks[idx], tracks[next]] = [tracks[next], tracks[idx]] - emit({ ...doc, tracks }) - } - - const addClip: AudioMixerActions['addClip'] = (trackId, clip) => { - const id = clip.id ?? genId('clip') - emit({ ...doc, tracks: mapTrack(trackId, t => ({ ...t, clips: [...t.clips, { ...clip, id }] })) }) - setSelection({ kind: 'clip', trackId, clipId: id }) - return id - } - - const removeClip: AudioMixerActions['removeClip'] = (trackId, clipId) => { - emit({ ...doc, tracks: mapTrack(trackId, t => ({ ...t, clips: t.clips.filter(c => c.id !== clipId) })) }) - setSelection(prev => (prev.kind === 'clip' && prev.clipId === clipId ? { kind: 'none' } : prev)) - } - - const updateClip: AudioMixerActions['updateClip'] = (trackId, clipId, patch) => { - emit({ - ...doc, - tracks: mapTrack(trackId, t => ({ - ...t, - clips: t.clips.map(c => (c.id === clipId ? { ...c, ...patch } : c)), - })), - }) - } - - const splitClip: AudioMixerActions['splitClip'] = (trackId, clipId, atMs) => { - const track = doc.tracks.find(t => t.id === trackId) - const clip = track?.clips.find(c => c.id === clipId) - if (!clip) return - const localMs = atMs - clip.start_ms - if (localMs <= 0 || localMs >= clipDuration(clip)) return - const splitSource = clip.source_in_ms + localMs - const left: MixerClip = { ...clip, source_out_ms: splitSource, fade_out_ms: 0 } - const right: MixerClip = { - ...clip, - id: genId('clip'), - start_ms: clip.start_ms + localMs, - source_in_ms: splitSource, - fade_in_ms: 0, - } - emit({ - ...doc, - tracks: mapTrack(trackId, t => ({ - ...t, - clips: t.clips.flatMap(c => (c.id === clipId ? [left, right] : [c])), - })), - }) - } - - const setMasterGain: AudioMixerActions['setMasterGain'] = gainDb => { - emit({ ...doc, master: { ...doc.master, gain_db: gainDb } }) - } - - const setLoop: AudioMixerActions['setLoop'] = patch => { - emit({ ...doc, master: { ...doc.master, loop: { ...doc.master.loop, ...patch } } }) - } - - const writeEffects = (target: EffectTarget, effects: AudioEffect[]) => { - if (target.scope === 'master') { - emit({ ...doc, master: { ...doc.master, effects } }) - } else { - emit({ ...doc, tracks: mapTrack(target.trackId, t => ({ ...t, effects })) }) - } - } - - const addEffect: AudioMixerActions['addEffect'] = (target, type) => { - const effect: AudioEffect = { id: genId('fx'), type, bypass: false, params: { ...EFFECT_DEFAULTS[type] } } - writeEffects(target, [...targetEffects(doc, target), effect]) - } - - const removeEffect: AudioMixerActions['removeEffect'] = (target, effectId) => { - writeEffects(target, targetEffects(doc, target).filter(e => e.id !== effectId)) - } - - const updateEffect: AudioMixerActions['updateEffect'] = (target, effectId, patch) => { - writeEffects( - target, - targetEffects(doc, target).map(e => - e.id === effectId ? { ...e, ...patch, params: { ...e.params, ...patch.params } } : e, - ), - ) - } - - return { - addTrack, - removeTrack, - updateTrack, - moveTrack, - addClip, - removeClip, - updateClip, - splitClip, - setMasterGain, - setLoop, - addEffect, - removeEffect, - updateEffect, - } - }, [doc, emit, genId, mapTrack]) - - const selectedTrack = - selection.kind === 'track' || selection.kind === 'clip' - ? doc.tracks.find(t => t.id === selection.trackId) ?? null - : null - const selectedClip = - selection.kind === 'clip' ? selectedTrack?.clips.find(c => c.id === selection.clipId) ?? null : null - - const selectionState: AudioMixerSelectionState = { - current: selection, - select: setSelection, - track: selectedTrack, - clip: selectedClip, - } - - const view: AudioMixerViewModel = { doc, actions, selection: selectionState, disabled } - - return { doc, actions, selection: selectionState, view } -} - -/* ---------------------------------------------------------------- * - * View — headers + timeline + inspector. No transport, no waveform. * - * Visual only: callback-driven, no Web Audio inside. * - * ---------------------------------------------------------------- */ - -export interface AudioMixerProps { - doc: AudioMixerDocument - actions: AudioMixerActions - selection: AudioMixerSelectionState - disabled?: boolean - loading?: boolean - className?: string - currentMs?: number - onSeek?: (ms: number) => void - id?: string -} - -const TRACK_COLORS = ['#0ea5e9', '#22c55e', '#f97316', '#a855f7', '#ef4444', '#14b8a6', '#eab308', '#ec4899'] - -export const AudioMixer: React.FC = ({ - doc, - actions, - selection, - disabled = false, - loading = false, - className = '', - currentMs = 0, - onSeek, - id: propId, -}) => { - const generatedId = useId() - const rootId = propId ?? generatedId - const [pxPerMs] = useState(0.05) - const [drag, setDrag] = useState<{ pointerId: number; trackId: string; clipId: string; mode: 'move' | 'trim-l' | 'trim-r'; startX: number; orig: MixerClip } | null>(null) - const lanesRef = useRef(null) - - const totalMs = Math.max(documentDuration(doc), 10000) - const laneWidth = totalMs * pxPerMs + 80 - - const onClipPointerDown = (e: React.PointerEvent, trackId: string, clip: MixerClip, mode: 'move' | 'trim-l' | 'trim-r') => { - if (disabled || e.button !== 0) return - // A second touch must not hijack an in-flight drag (multi-touch). - if (drag) return - e.stopPropagation() - try { - (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId) - } catch { /* pointer already gone — drag works without capture */ } - setDrag({ pointerId: e.pointerId, trackId, clipId: clip.id, mode, startX: e.clientX, orig: clip }) - selection.select({ kind: 'clip', trackId, clipId: clip.id }) - } - - const onLanesPointerMove = (e: React.PointerEvent) => { - if (!drag || e.pointerId !== drag.pointerId) return - const deltaMs = Math.round((e.clientX - drag.startX) / pxPerMs) - const o = drag.orig - if (drag.mode === 'move') { - actions.updateClip(drag.trackId, drag.clipId, { start_ms: Math.max(0, o.start_ms + deltaMs) }) - } else if (drag.mode === 'trim-l') { - const nextIn = Math.min(o.source_out_ms - 10, Math.max(0, o.source_in_ms + deltaMs)) - actions.updateClip(drag.trackId, drag.clipId, { source_in_ms: nextIn, start_ms: Math.max(0, o.start_ms + (nextIn - o.source_in_ms)) }) - } else { - const nextOut = Math.max(o.source_in_ms + 10, o.source_out_ms + deltaMs) - actions.updateClip(drag.trackId, drag.clipId, { source_out_ms: nextOut }) - } - } - - const onLanesPointerUp = (e: React.PointerEvent) => { - if (drag && e.pointerId !== drag.pointerId) return - try { - (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId) - } catch { /* capture may already be released */ } - setDrag(null) - } - - const seekFromEvent = (clientX: number) => { - const el = lanesRef.current - if (!el || !onSeek) return - const rect = el.getBoundingClientRect() - const ms = (clientX - rect.left + el.scrollLeft) / pxPerMs - onSeek(Math.max(0, Math.round(ms))) - } - - const trackColor = useCallback((trackId: string) => TRACK_COLORS[doc.tracks.findIndex(t => t.id === trackId) % TRACK_COLORS.length], [doc.tracks]) - - const ticks = useMemo(() => { - const stepMs = pxPerMs > 0.12 ? 1000 : pxPerMs > 0.04 ? 5000 : 15000 - const out: number[] = [] - for (let ms = 0; ms <= totalMs; ms += stepMs) out.push(ms) - return { stepMs, out } - }, [pxPerMs, totalMs]) - - if (loading) { - return ( -
    -
    - -
    -
    - ) - } - - const loop = doc.master.loop - const sel = selection.current - - return ( -
    -
    -
    -
    -
    - {doc.tracks.map(track => { - const active = sel.kind !== 'none' && 'trackId' in sel && sel.trackId === track.id - return ( -
    selection.select({ kind: 'track', trackId: track.id })} - > - actions.updateTrack(track.id, { name: e.target.value })} - onClick={e => e.stopPropagation()} - /> -
    - - - e.stopPropagation()} - onChange={e => actions.updateTrack(track.id, { gain_db: Number(e.target.value) })} - /> -
    -
    - ) - })} -
    - -
    -
    -
    seekFromEvent(e.clientX)} - > - {ticks.out.map(ms => ( - - {formatTime(ms)} - - ))} -
    - -
    - {loop.enabled && ( -
    - )} - {doc.tracks.map(track => ( -
    - {track.clips.map(clip => { - const dur = clipDuration(clip) - const w = Math.max(8, dur * pxPerMs) - const isSel = sel.kind === 'clip' && sel.clipId === clip.id - return ( -
    onClipPointerDown(e, track.id, clip, 'move')} - > - onClipPointerDown(e, track.id, clip, 'trim-l')} - /> - onClipPointerDown(e, track.id, clip, 'trim-r')} - /> -
    - ) - })} -
    - ))} -
    -
    -
    -
    -
    - - -
    -
    - ) -} - -/* ---------------------------------------------------------------- * - * Inspector — parameters for the current selection. * - * ---------------------------------------------------------------- */ - -interface InspectorProps { - doc: AudioMixerDocument - actions: AudioMixerActions - selection: AudioMixerSelectionState - disabled: boolean - currentMs: number -} - -const EffectChain: React.FC<{ target: EffectTarget; effects: AudioEffect[]; actions: AudioMixerActions; disabled: boolean }> = ({ target, effects, actions, disabled }) => ( -
    -
    Effects
    - {effects.map(fx => ( -
    -
    - {fx.type} - - -
    -
    - {Object.entries(fx.params).map(([key, val]) => ( - - ))} -
    -
    - ))} - -
    -) - -const AudioMixerInspector: React.FC = ({ doc, actions, selection, disabled, currentMs }) => { - const sel = selection.current - - if (sel.kind === 'none') { - return ( -
    -
    Select a track, clip, or the master bus
    - -
    - ) - } - - if (sel.kind === 'master') { - const loop = doc.master.loop - return ( -
    -
    Master bus
    - -
    Loop region
    -
    - - -
    - -
    - ) - } - - const track = selection.track - if (!track) return
    - - if (sel.kind === 'clip') { - const clip = selection.clip - if (!clip) return
    - const update = (patch: Partial) => actions.updateClip(track.id, clip.id, patch) - return ( -
    -
    - Clip - -
    - {[ - ['Start (ms)', 'start_ms'], - ['Source in (ms)', 'source_in_ms'], - ['Source out (ms)', 'source_out_ms'], - ['Gain (dB)', 'gain_db'], - ['Fade in (ms)', 'fade_in_ms'], - ['Fade out (ms)', 'fade_out_ms'], - ].map(([label, field]) => ( - - ))} - -
    - ) - } - - return ( -
    -
    - {track.name} - -
    - - -
    - - -
    - -
    - ) -} diff --git a/react-components/src/Avatar.tsx b/react-components/src/Avatar.tsx deleted file mode 100644 index 74233f67..00000000 --- a/react-components/src/Avatar.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react' - -export interface AvatarProps extends React.HTMLAttributes { - src?: string - alt?: string - name?: string - size?: 'small' | 'default' | 'large' - status?: 'online' | 'offline' | 'busy' | 'away' - variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' -} - -const getInitials = (name: string): string => { - const parts = name.trim().split(/\s+/) - if (parts.length === 0) return '?' - if (parts.length === 1) return parts[0].substring(0, 2).toUpperCase() - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() -} - -export const Avatar: React.FC = ({ - src, - alt, - name, - size = 'default', - status, - variant = 'primary', - ...props -}) => { - const [imageError, setImageError] = React.useState(false) - const [imageLoaded, setImageLoaded] = React.useState(false) - - React.useEffect(() => { - setImageError(false) - setImageLoaded(false) - }, [src]) - - const handleImageError = () => { - setImageError(true) - } - - const handleImageLoad = () => { - setImageLoaded(true) - } - - const showImage = src && !imageError - const showInitials = !showImage && name - const showPlaceholder = !showImage && !name - - let avatarClass = `${props.className || ''} component component-avatar avatar avatar-${size}` - if (!showImage) { - avatarClass += ` avatar-initials bg-${variant}` - } - - const initials = name ? getInitials(name) : '?' - - return ( -
    - {showImage && ( - {alt - )} - {showInitials && {initials}} - {showPlaceholder && ?} - {status && } -
    - ) -} diff --git a/react-components/src/Badge.tsx b/react-components/src/Badge.tsx deleted file mode 100644 index fab83326..00000000 --- a/react-components/src/Badge.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react' - -export interface BadgeProps extends React.HTMLAttributes { - children?: React.ReactNode - variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' - pill?: boolean - size?: 'sm' | 'md' | 'lg' -} - -export const Badge: React.FC = ({ children, variant = 'secondary', pill = false, size = 'md', ...props }) => { - let badgeClass = `${props.className || ''} component component-badge badge bg-${variant}` - if (pill) badgeClass += ' rounded-pill' - if (size === 'sm') badgeClass += ' component-badge--sm' - if (size === 'lg') badgeClass += ' component-badge--lg' - return ( - - {children} - - ) -} diff --git a/react-components/src/BadgeRow.tsx b/react-components/src/BadgeRow.tsx deleted file mode 100644 index c1064ff0..00000000 --- a/react-components/src/BadgeRow.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react' - -export interface BadgeRowItem { - label: string - value: string - color?: string - href?: string -} - -export interface BadgeRowProps extends React.HTMLAttributes { - badges: BadgeRowItem[] - size?: 'sm' | 'md' -} - -export const BadgeRow: React.FC = ({ - badges, - size = 'md', - className = '', - ...rest -}) => { - const rootClass = `component component-badge-row component-badge-row--${size} ${className}`.trim() - - return ( -
    - {badges.map((badge, index) => { - const Tag = badge.href ? 'a' : 'span' - const props = badge.href - ? { href: badge.href, target: '_blank', rel: 'noopener noreferrer' } - : {} - const style = badge.color ? { '--bdr-accent': badge.color } as React.CSSProperties : undefined - return ( - - {badge.label} - {badge.value} - - ) - })} -
    - ) -} - -export default BadgeRow diff --git a/react-components/src/Banner.tsx b/react-components/src/Banner.tsx deleted file mode 100644 index 5476c9fa..00000000 --- a/react-components/src/Banner.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { useState } from 'react' -import { Icon } from './Icon' -import { IconButton } from './IconButton' - -// ── Types ────────────────────────────────────────────────────────────────────── - -export interface BannerProps extends React.HTMLAttributes { - variant?: 'info' | 'warning' | 'success' | 'error' - dismissible?: boolean - /** localStorage key. If set, and key exists, banner will not render */ - storageKey?: string - icon?: string - /** Slot for an action element (e.g. a Button or link) */ - action?: React.ReactNode - onDismiss?: () => void - children: React.ReactNode - className?: string -} - -// ── Default icons per variant ────────────────────────────────────────────────── - -const DEFAULT_ICONS: Record, string> = { - info: 'info-circle', - warning: 'exclamation-triangle', - success: 'check-circle', - error: 'x-circle', -} - -// ── Component ────────────────────────────────────────────────────────────────── - -export const Banner: React.FC = ({ - variant = 'info', - dismissible = false, - storageKey, - icon, - action, - onDismiss, - children, - className = '', - ...rest -}) => { - const [dismissed, setDismissed] = useState(() => { - if (!storageKey) return false - try { - return localStorage.getItem(storageKey) === 'dismissed' - } catch { - return false - } - }) - - if (dismissed) return null - - const handleDismiss = () => { - if (storageKey) { - try { localStorage.setItem(storageKey, 'dismissed') } catch { /* ignore */ } - } - setDismissed(true) - onDismiss?.() - } - - const iconName = icon ?? DEFAULT_ICONS[variant] - - const rootClass = [ - 'component component-banner', - `component-banner--${variant}`, - dismissible ? 'component-banner--dismissible' : '', - className, - ].filter(Boolean).join(' ') - - return ( -
    - - -
    - {children} -
    - - {action && ( -
    - {action} -
    - )} - - {dismissible && ( - - )} -
    - ) -} diff --git a/react-components/src/BasicLayout/index.tsx b/react-components/src/BasicLayout/index.tsx deleted file mode 100644 index a5f8b97b..00000000 --- a/react-components/src/BasicLayout/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' - -export interface BasicLayoutProps { - children?: React.ReactNode - brand?: React.ReactNode -} - -export const BasicLayout: React.FC = ({ children, brand }) => { - return ( -
    - {brand && ( -
    -
    - {brand} -
    -
    - )} -
    -
    - {children} -
    -
    -
    - ) -} diff --git a/react-components/src/BenchmarkChart.tsx b/react-components/src/BenchmarkChart.tsx deleted file mode 100644 index d13bec03..00000000 --- a/react-components/src/BenchmarkChart.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useMemo } from 'react' - -export interface BenchmarkBar { - label: string - value: number - unit?: string - color?: string - baseline?: boolean -} - -export interface BenchmarkChartProps extends Omit, 'title'> { - bars: BenchmarkBar[] - lowerIsBetter?: boolean - scale?: 'linear' | 'log' - title?: React.ReactNode -} - -const formatValue = (n: number): string => { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` - return n.toLocaleString() -} - -export const BenchmarkChart: React.FC = ({ - bars, - lowerIsBetter = false, - scale = 'linear', - title, - className = '', - ...rest -}) => { - const { max, leaderIndex } = useMemo(() => { - const values = bars.map((b) => b.value) - const m = Math.max(...values, 1) - const target = lowerIsBetter ? Math.min(...values) : Math.max(...values) - const idx = values.findIndex((v) => v === target) - return { max: m, leaderIndex: idx } - }, [bars, lowerIsBetter]) - - const computeWidth = (value: number): number => { - if (scale === 'log') { - const logVal = Math.log10(Math.max(value, 1)) - const logMax = Math.log10(Math.max(max, 1)) - return logMax > 0 ? (logVal / logMax) * 100 : 0 - } - return (value / max) * 100 - } - - const rootClass = `component component-benchmark-chart ${className}`.trim() - - return ( -
    - {title ?

    {title}

    : null} -
    - {bars.map((bar, i) => { - const isLeader = i === leaderIndex - const accent = bar.color - ? ({ '--bch-accent': bar.color } as React.CSSProperties) - : undefined - return ( -
    -
    {bar.label}
    -
    -
    -
    -
    - {formatValue(bar.value)} - {bar.unit ? {bar.unit} : null} -
    -
    - ) - })} -
    -
    - {lowerIsBetter ? 'Lower is better' : 'Higher is better'} -
    -
    - ) -} - -export default BenchmarkChart diff --git a/react-components/src/BitmapFontGenerator.tsx b/react-components/src/BitmapFontGenerator.tsx deleted file mode 100644 index 46c4df28..00000000 --- a/react-components/src/BitmapFontGenerator.tsx +++ /dev/null @@ -1,633 +0,0 @@ -import React, { useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react' - -export interface BitmapFontFill { - type: 'solid' | 'gradient' - color?: string - gradientColors?: string[] - gradientAngle?: number - /** Gradient shape. Defaults to `linear`. */ - gradientType?: 'linear' | 'radial' -} - -export interface BitmapFontBorder { - color: string - thickness: number - /** Where the stroke sits relative to the glyph edge. Defaults to `center`. */ - align?: 'inner' | 'outer' | 'center' -} - -export interface BitmapFontDropShadow { - color: string - size: number - /** Horizontal offset. Defaults to `size * 0.5`. */ - offsetX?: number - /** Vertical offset. Defaults to `size * 0.5`. */ - offsetY?: number - /** Blur radius. Defaults to `size`. */ - blur?: number -} - -/** Symmetric outer glow (a blurred, zero-offset halo). */ -export interface BitmapFontGlow { - color: string - size: number -} - -export type BitmapFontExportFormat = 'xml' | 'json' | 'fnt' - -export interface BitmapFontGlyph { - char: string - x: number - y: number - width: number - height: number - xOffset: number - yOffset: number - xAdvance: number -} - -export interface BitmapFontOutput { - png: Blob - /** Always the BMFont XML, regardless of `exportFormat` (back-compat). */ - xml: string - /** Serialized descriptor in the chosen `exportFormat`. */ - text: string - format: BitmapFontExportFormat - glyphs: BitmapFontGlyph[] - width: number - height: number -} - -export interface BitmapFontGeneratorProps { - fontFamily?: string - fill?: BitmapFontFill - /** Single border. Ignored when `borders` is provided. */ - border?: BitmapFontBorder - /** Stacked outlines, drawn thickest-first (concentric). Overrides `border`. */ - borders?: BitmapFontBorder[] - fontSize?: number - dropShadow?: BitmapFontDropShadow - glow?: BitmapFontGlow - glyphs?: string - text?: string - // ── Spacing & layout ── - /** Extra px added to each glyph's advance/cell width. */ - letterSpacing?: number - /** Px padding baked around each glyph cell. Defaults to 2. */ - padding?: number - /** Glyphs packed per atlas row. Defaults to 16. */ - glyphsPerRow?: number - /** Override the reported BMFont lineHeight. Defaults to the cell height. */ - lineHeight?: number - /** Round the atlas dimensions up to the next power of two. */ - powerOfTwo?: boolean - // ── Export ── - /** Multiplies all geometry for a hi-res atlas. Defaults to 1. */ - scale?: number - /** Atlas background. Defaults to transparent. */ - background?: string - /** Descriptor format returned in `output.text`. Defaults to `xml`. */ - exportFormat?: BitmapFontExportFormat - onGenerate?: (output: BitmapFontOutput) => void - disabled?: boolean - className?: string -} - -export interface BitmapFontGeneratorHandle { - /** - * Renders the atlas, fires `onGenerate`, and resolves with the output. - * Resolves `null` if `disabled` or a generation is already in flight. - */ - generate: () => Promise -} - -const DEFAULT_GLYPHS = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?-+:;\'\"()[]{}/@#$%^&*~`<>=_\\|' - -interface ResolvedEffect { - color: string - blur: number - offsetX: number - offsetY: number -} - -/** Fully-resolved, scale-applied generation config used by the renderer. */ -interface RenderConfig { - fontFamily: string - fontSize: number - fill: BitmapFontFill - borders: BitmapFontBorder[] - shadow?: ResolvedEffect - glow?: ResolvedEffect - padding: number - letterSpacing: number -} - -const uniqueChars = (str: string): string => { - const seen = new Set() - let result = '' - for (const ch of str) { - if (!seen.has(ch)) { - seen.add(ch) - result += ch - } - } - return result -} - -const nextPow2 = (n: number): number => { - let p = 1 - while (p < n) p <<= 1 - return p -} - -const resolveShadow = (ds: BitmapFontDropShadow | undefined, scale: number): ResolvedEffect | undefined => { - if (!ds || ds.size <= 0) return undefined - return { - color: ds.color, - blur: (ds.blur ?? ds.size) * scale, - offsetX: (ds.offsetX ?? ds.size * 0.5) * scale, - offsetY: (ds.offsetY ?? ds.size * 0.5) * scale, - } -} - -const resolveGlow = (glow: BitmapFontGlow | undefined, scale: number): ResolvedEffect | undefined => { - if (!glow || glow.size <= 0) return undefined - return { color: glow.color, blur: glow.size * scale, offsetX: 0, offsetY: 0 } -} - -/** Builds the renderer config, applying `scale` to every geometric value. */ -const resolveConfig = ( - props: Required< - Pick - > & { - borders: BitmapFontBorder[] - dropShadow?: BitmapFontDropShadow - glow?: BitmapFontGlow - scale: number - }, -): RenderConfig => { - const s = props.scale - return { - fontFamily: props.fontFamily, - fontSize: props.fontSize * s, - fill: props.fill, - borders: props.borders.map((b) => ({ ...b, thickness: b.thickness * s })), - shadow: resolveShadow(props.dropShadow, s), - glow: resolveGlow(props.glow, s), - padding: props.padding * s, - letterSpacing: props.letterSpacing * s, - } -} - -const maxBorderThickness = (borders: BitmapFontBorder[]): number => - borders.reduce((m, b) => Math.max(m, b.thickness), 0) - -/** Maximum px any effect bleeds past the glyph bounds. */ -const effectExtent = (cfg: RenderConfig): number => { - const shadow = cfg.shadow - ? cfg.shadow.blur + Math.max(Math.abs(cfg.shadow.offsetX), Math.abs(cfg.shadow.offsetY)) - : 0 - const glow = cfg.glow ? cfg.glow.blur : 0 - return Math.max(shadow, glow) -} - -const createFillStyle = ( - ctx: CanvasRenderingContext2D, - fill: BitmapFontFill, - gx: number, - gy: number, - gw: number, - gh: number, -): string | CanvasGradient => { - if (fill.type === 'solid' || !fill.gradientColors?.length) { - return fill.color ?? '#ffffff' - } - const cx = gx + gw / 2 - const cy = gy + gh / 2 - const colors = fill.gradientColors - let gradient: CanvasGradient - if (fill.gradientType === 'radial') { - gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(gw, gh) / 2) - } else { - const rad = ((fill.gradientAngle ?? 0) * Math.PI) / 180 - const len = Math.max(gw, gh) / 2 - gradient = ctx.createLinearGradient( - cx - Math.cos(rad) * len, - cy - Math.sin(rad) * len, - cx + Math.cos(rad) * len, - cy + Math.sin(rad) * len, - ) - } - colors.forEach((c, i) => gradient.addColorStop(i / Math.max(colors.length - 1, 1), c)) - return gradient -} - -const measureGlyphs = ( - ctx: CanvasRenderingContext2D, - chars: string, - cfg: RenderConfig, -): { widths: number[]; maxHeight: number; ascent: number } => { - ctx.font = `${cfg.fontSize}px "${cfg.fontFamily}"` - const metrics = ctx.measureText('M') - const ascent = metrics.actualBoundingBoxAscent ?? cfg.fontSize * 0.8 - const descent = metrics.actualBoundingBoxDescent ?? cfg.fontSize * 0.2 - const baseHeight = Math.ceil(ascent + descent) - - const border = maxBorderThickness(cfg.borders) - const extra = border * 2 + effectExtent(cfg) + cfg.padding * 2 - const maxHeight = baseHeight + extra - - const widths: number[] = [] - for (const ch of chars) { - const m = ctx.measureText(ch) - widths.push(Math.ceil(m.width) + extra + cfg.letterSpacing) - } - - return { widths, maxHeight, ascent: Math.ceil(ascent) } -} - -/** Paints a blurred/offset copy of the glyph to cast a shadow or glow. */ -const castEffect = (ctx: CanvasRenderingContext2D, char: string, x: number, y: number, fx: ResolvedEffect) => { - ctx.save() - ctx.shadowColor = fx.color - ctx.shadowBlur = fx.blur - ctx.shadowOffsetX = fx.offsetX - ctx.shadowOffsetY = fx.offsetY - // The source paint is covered by the real fill/borders later; only its - // shadow survives. Use the effect colour so a zero-blur glow still reads. - ctx.fillStyle = fx.color - ctx.fillText(char, x, y) - ctx.restore() -} - -const renderGlyph = ( - ctx: CanvasRenderingContext2D, - char: string, - x: number, - y: number, - cfg: RenderConfig, - cellHeight: number, -) => { - ctx.font = `${cfg.fontSize}px "${cfg.fontFamily}"` - ctx.textBaseline = 'top' - - const pad = maxBorderThickness(cfg.borders) + cfg.padding - const drawX = x + pad - const drawY = y + pad - - if (cfg.glow) castEffect(ctx, char, drawX, drawY, cfg.glow) - if (cfg.shadow) castEffect(ctx, char, drawX, drawY, cfg.shadow) - - // Outer/center borders, thickest first → concentric rings under the fill. - const outer = cfg.borders.filter((b) => b.align !== 'inner').sort((a, b) => b.thickness - a.thickness) - ctx.lineJoin = 'round' - for (const b of outer) { - if (b.thickness <= 0) continue - ctx.strokeStyle = b.color - ctx.lineWidth = b.align === 'center' ? b.thickness : b.thickness * 2 - ctx.strokeText(char, drawX, drawY) - } - - const charW = ctx.measureText(char).width - ctx.fillStyle = createFillStyle(ctx, cfg.fill, drawX, drawY, charW, cellHeight) - ctx.fillText(char, drawX, drawY) - - // Inner borders clip to the painted fill via source-atop. - const inner = cfg.borders.filter((b) => b.align === 'inner') - if (inner.length) { - ctx.save() - ctx.globalCompositeOperation = 'source-atop' - ctx.lineJoin = 'round' - for (const b of inner) { - if (b.thickness <= 0) continue - ctx.strokeStyle = b.color - ctx.lineWidth = b.thickness * 2 - ctx.strokeText(char, drawX, drawY) - } - ctx.restore() - } -} - -const buildXml = ( - fontFamily: string, - fontSize: number, - lineHeight: number, - base: number, - width: number, - height: number, - glyphs: BitmapFontGlyph[], -): string => { - const pageFile = `${fontFamily.replace(/\s/g, '_')}_${fontSize}.png` - const lines = [ - '', - ``, - ` `, - ` `, - ` `, - ` `, - ` `, - ` `, - ] - for (const g of glyphs) { - lines.push( - ` `, - ) - } - lines.push(` `, ``) - return lines.join('\n') -} - -const buildFnt = ( - fontFamily: string, - fontSize: number, - lineHeight: number, - base: number, - width: number, - height: number, - glyphs: BitmapFontGlyph[], -): string => { - const pageFile = `${fontFamily.replace(/\s/g, '_')}_${fontSize}.png` - const lines = [ - `info face="${fontFamily}" size=${fontSize} bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=1 aa=1 padding=0,0,0,0 spacing=0,0`, - `common lineHeight=${lineHeight} base=${base} scaleW=${width} scaleH=${height} pages=1 packed=0`, - `page id=0 file="${pageFile}"`, - `chars count=${glyphs.length}`, - ] - for (const g of glyphs) { - lines.push( - `char id=${g.char.charCodeAt(0)} x=${g.x} y=${g.y} width=${g.width} height=${g.height} xoffset=${g.xOffset} yoffset=${g.yOffset} xadvance=${g.xAdvance} page=0 chnl=15`, - ) - } - return lines.join('\n') -} - -const buildJson = ( - fontFamily: string, - fontSize: number, - lineHeight: number, - base: number, - width: number, - height: number, - glyphs: BitmapFontGlyph[], -): string => - JSON.stringify( - { - info: { face: fontFamily, size: fontSize }, - common: { lineHeight, base, scaleW: width, scaleH: height, pages: 1 }, - chars: glyphs.map((g) => ({ id: g.char.charCodeAt(0), ...g })), - }, - null, - 2, - ) - -const generateBitmapFont = ( - cfg: RenderConfig, - glyphString: string, - opts: { - glyphsPerRow: number - lineHeight?: number - powerOfTwo: boolean - background?: string - exportFormat: BitmapFontExportFormat - }, -): { canvas: HTMLCanvasElement; glyphs: BitmapFontGlyph[]; xml: string; text: string } => { - const chars = uniqueChars(glyphString) - const measure = document.createElement('canvas') - const mCtx = measure.getContext('2d')! - mCtx.font = `${cfg.fontSize}px "${cfg.fontFamily}"` - - const { widths, maxHeight, ascent } = measureGlyphs(mCtx, chars, cfg) - - const perRow = Math.max(1, opts.glyphsPerRow) - const rows = Math.ceil(chars.length / perRow) - const maxRowWidth = Math.max( - 1, - ...Array.from({ length: rows }, (_, r) => { - let w = 0 - for (let i = r * perRow; i < Math.min((r + 1) * perRow, chars.length); i++) { - w += widths[i] - } - return w - }), - ) - - const canvas = document.createElement('canvas') - canvas.width = opts.powerOfTwo ? nextPow2(maxRowWidth) : maxRowWidth - canvas.height = opts.powerOfTwo ? nextPow2(rows * maxHeight) : rows * maxHeight - const ctx = canvas.getContext('2d')! - if (opts.background) { - ctx.fillStyle = opts.background - ctx.fillRect(0, 0, canvas.width, canvas.height) - } else { - ctx.clearRect(0, 0, canvas.width, canvas.height) - } - - const glyphData: BitmapFontGlyph[] = [] - const pad = maxBorderThickness(cfg.borders) + cfg.padding - let cx = 0 - let cy = 0 - let col = 0 - - for (let i = 0; i < chars.length; i++) { - const ch = chars[i] - const w = widths[i] - - renderGlyph(ctx, ch, cx, cy, cfg, maxHeight) - - glyphData.push({ - char: ch, - x: cx, - y: cy, - width: w, - height: maxHeight, - xOffset: 0, - yOffset: 0, - xAdvance: w - pad, - }) - - cx += w - col++ - if (col >= perRow) { - col = 0 - cx = 0 - cy += maxHeight - } - } - - const lineHeight = opts.lineHeight ?? maxHeight - const xml = buildXml(cfg.fontFamily, cfg.fontSize, lineHeight, ascent, canvas.width, canvas.height, glyphData) - const text = - opts.exportFormat === 'json' - ? buildJson(cfg.fontFamily, cfg.fontSize, lineHeight, ascent, canvas.width, canvas.height, glyphData) - : opts.exportFormat === 'fnt' - ? buildFnt(cfg.fontFamily, cfg.fontSize, lineHeight, ascent, canvas.width, canvas.height, glyphData) - : xml - - return { canvas, glyphs: glyphData, xml, text } -} - -export const BitmapFontGenerator = forwardRef( - ( - { - fontFamily = 'Arial', - fill = { type: 'solid', color: '#ffffff' }, - border, - borders, - fontSize = 32, - dropShadow, - glow, - glyphs = DEFAULT_GLYPHS, - text = 'Hello World!', - letterSpacing = 0, - padding = 2, - glyphsPerRow = 16, - lineHeight, - powerOfTwo = false, - scale = 1, - background, - exportFormat = 'xml', - onGenerate, - disabled = false, - className = '', - }, - ref, - ) => { - const previewRef = useRef(null) - const generatingRef = useRef(false) - - const resolvedBorders = useMemo(() => borders ?? (border ? [border] : []), [borders, border]) - - const drawPreview = useCallback(() => { - const canvas = previewRef.current - if (!canvas) return - const ctx = canvas.getContext('2d') - if (!ctx) return - - const dpr = window.devicePixelRatio || 1 - const displayW = canvas.clientWidth - const displayH = canvas.clientHeight - canvas.width = displayW * dpr - canvas.height = displayH * dpr - ctx.scale(dpr, dpr) - - ctx.clearRect(0, 0, displayW, displayH) - ctx.fillStyle = background ?? '#1a1a2e' - ctx.fillRect(0, 0, displayW, displayH) - - // Preview renders at scale 1 (display size), ignoring export `scale`. - const cfg = resolveConfig({ - fontFamily, - fontSize, - fill, - borders: resolvedBorders, - dropShadow, - glow, - padding, - letterSpacing, - scale: 1, - }) - - ctx.font = `${cfg.fontSize}px "${cfg.fontFamily}"` - ctx.textBaseline = 'top' - - const pad = maxBorderThickness(cfg.borders) + cfg.padding - const advance = effectExtent(cfg) - let x = 16 - let y = 16 - - for (const ch of text) { - const m = ctx.measureText(ch) - const charW = m.width + pad * 2 + cfg.letterSpacing - - if (x + charW > displayW - 16) { - x = 16 - y += cfg.fontSize + pad * 2 + advance + 8 - } - - renderGlyph(ctx, ch, x, y, cfg, cfg.fontSize + 4) - x += charW - } - }, [fontFamily, fill, resolvedBorders, fontSize, dropShadow, glow, padding, letterSpacing, background, text]) - - useEffect(() => { - drawPreview() - }, [drawPreview]) - - const handleGenerate = useCallback(async (): Promise => { - if (disabled || generatingRef.current) return null - generatingRef.current = true - - try { - const cfg = resolveConfig({ - fontFamily, - fontSize, - fill, - borders: resolvedBorders, - dropShadow, - glow, - padding, - letterSpacing, - scale, - }) - - const { canvas, glyphs: glyphData, xml, text: descriptor } = generateBitmapFont(cfg, glyphs, { - glyphsPerRow, - lineHeight, - powerOfTwo, - background, - exportFormat, - }) - - const blob = await new Promise((resolve, reject) => { - canvas.toBlob((b) => { - if (b) resolve(b) - else reject(new Error('Failed to generate PNG')) - }, 'image/png') - }) - - const output: BitmapFontOutput = { - png: blob, - xml, - text: descriptor, - format: exportFormat, - glyphs: glyphData, - width: canvas.width, - height: canvas.height, - } - onGenerate?.(output) - return output - } finally { - generatingRef.current = false - } - }, [ - fontFamily, - fontSize, - fill, - resolvedBorders, - dropShadow, - glow, - glyphs, - letterSpacing, - padding, - glyphsPerRow, - lineHeight, - powerOfTwo, - scale, - background, - exportFormat, - onGenerate, - disabled, - ]) - - useImperativeHandle(ref, () => ({ generate: handleGenerate }), [handleGenerate]) - - return ( -
    - -
    - ) - }, -) - -BitmapFontGenerator.displayName = 'BitmapFontGenerator' diff --git a/react-components/src/Brand.tsx b/react-components/src/Brand.tsx deleted file mode 100644 index beb6fb6b..00000000 --- a/react-components/src/Brand.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { FC, HTMLAttributes, ReactNode } from 'react' -import { Badge } from './Badge' - - -export interface BrandProps extends HTMLAttributes { - primaryText?: ReactNode - secondaryText?: ReactNode - label?: ReactNode - color?: string // hex color for underline - xlarge?: boolean // scale 300% -} - - -export const Brand: FC = ({ - primaryText, - secondaryText, - label, - color, - xlarge, - className, - onClick = undefined, - ...rest -}) => { - const clickable = typeof onClick === 'function' - const classList = ['component component-brand', className] - if (clickable) { - classList.push('component-brand--clickable') - } - if (xlarge) { - classList.push('component-brand--xlarge') - } - const rootClass = classList.filter(Boolean).join(' ') - - // Style for underline color - const underlineStyle = color - ? { textDecoration: `underline`, textDecorationColor: color } - : { textDecoration: 'underline' } - - return ( -
    - - {primaryText && {primaryText}} - {secondaryText && {secondaryText}} - - {label && ( -
    - {label} -
    - )} -
    - ) -} diff --git a/react-components/src/Breadcrumb.tsx b/react-components/src/Breadcrumb.tsx deleted file mode 100644 index c690520d..00000000 --- a/react-components/src/Breadcrumb.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react' - -// ── Types ────────────────────────────────────────────────────────────────────── - -export interface BreadcrumbItem { - label: string - href?: string - onClick?: (e: React.MouseEvent) => void -} - -export interface BreadcrumbProps extends React.HTMLAttributes { - items: BreadcrumbItem[] - /** - * Custom separator between crumbs. - * Default: '/' - */ - separator?: React.ReactNode - /** - * Maximum number of visible crumbs before collapsing middle ones. - * When exceeded, the first and last `maxItems - 1` crumbs are shown - * with an ellipsis button that expands the full list. - * Default: 0 (no collapse) - */ - maxItems?: number - className?: string -} - -export const Breadcrumb: React.FC = ({ - items, - separator = '/', - maxItems = 0, - className = '', - ...rest -}) => { - const [expanded, setExpanded] = React.useState(false) - - const shouldCollapse = maxItems > 0 && !expanded && items.length > maxItems - - let visible: (BreadcrumbItem | null)[] - if (shouldCollapse) { - // Show first item, ellipsis placeholder (null), last (maxItems - 1) items - const tail = items.slice(items.length - (maxItems - 1)) - visible = [items[0], null, ...tail] - } else { - visible = items - } - - const rootClass = ['component component-breadcrumb', className].filter(Boolean).join(' ') - - return ( - - ) -} diff --git a/react-components/src/BriefCard.tsx b/react-components/src/BriefCard.tsx deleted file mode 100644 index 12878276..00000000 --- a/react-components/src/BriefCard.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react' - -export type BriefDifficulty = 'easy' | 'medium' | 'hard' - -export interface BriefCardProps extends Omit, 'id' | 'title'> { - id: string - icon?: React.ReactNode - difficulty: BriefDifficulty - title: React.ReactNode - body: React.ReactNode - metaLeft?: React.ReactNode - metaRight?: React.ReactNode - onClick?: () => void -} - -export const BriefCard: React.FC = ({ - id, - icon, - difficulty, - title, - body, - metaLeft, - metaRight, - onClick, - className = '', - ...rest -}) => { - const interactive = typeof onClick === 'function' - const rootClass = [ - 'component component-brief-card', - `component-brief-card--${difficulty}`, - interactive ? 'component-brief-card--interactive' : '', - className, - ].filter(Boolean).join(' ') - - const interactiveProps: React.HTMLAttributes = interactive - ? { - role: 'button', - tabIndex: 0, - onClick, - onKeyDown: (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onClick!() - } - }, - } - : {} - - return ( -
    - {icon &&
    {icon}
    } -
    {id}
    -
    {difficulty.toUpperCase()}
    -

    {title}

    -

    {body}

    - {(metaLeft || metaRight) && ( -
    - {metaLeft} - {metaRight} -
    - )} -
    - ) -} diff --git a/react-components/src/Build.tsx b/react-components/src/Build.tsx deleted file mode 100644 index 269c9a5d..00000000 --- a/react-components/src/Build.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react' -import toolcase from '@toolcase/base' -import { ActionItems, ActionItem } from './ActionItems' -import { Badge } from './Badge' -import { Icon } from './Icon' -import { Spinner } from './Spinner' -import { Skeleton } from './Skeleton' - -export interface BuildTag { - id: string - name: string -} - -export type BuildStatus = 'pass' | 'fail' | 'running' | 'queued' - -export interface BuildProps { - name?: string - date?: string - size?: number - duration?: number - status?: BuildStatus - badge?: string - onClick?: () => void - badgeVariant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' - menuItems?: ActionItem[] - onMenuItemClick?: (key: string) => void - className?: string - loading?: boolean -} - -function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms` - const seconds = ms / 1000 - if (seconds < 60) return `${seconds.toFixed(1)}s` - const minutes = Math.floor(seconds / 60) - const remaining = Math.round(seconds % 60) - return `${minutes}m ${remaining}s` -} - -export const Build: React.FC = ({ - name = '[BUILD_NAME]', - date = '', - size = 0, - duration = 0, - status = 'pass', - badge, - onClick, - badgeVariant = 'secondary', - menuItems = [], - onMenuItemClick, - className = '', - loading = false, -}) => { - const rootClass = [ - 'component component-build', - status === 'running' ? 'component-build--running' : '', - className, - ].filter(Boolean).join(' ') - - if (loading) { - return ( -
    - -
    - - -
    - - -
    - ) - } - - return ( -
    -
    - {status === 'running' ? ( - - ) : ( - - )} -
    -
    -
    - {name} - {badge && {badge}} -
    -
    - {date && {date}} -
    -
    -
    {toolcase.formatByteSize(size)}
    -
    {formatDuration(duration)}
    - {menuItems.length > 0 && ( - - )} -
    - ) -} diff --git a/react-components/src/BundleBar.tsx b/react-components/src/BundleBar.tsx deleted file mode 100644 index 9e0fc077..00000000 --- a/react-components/src/BundleBar.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { FC, HTMLAttributes, ReactNode } from 'react' - -export interface BundleBarChip { - label: string - active?: boolean - icon?: ReactNode -} - -export interface BundleBarProps extends HTMLAttributes { - chips?: BundleBarChip[] - segments?: number - filledSegments?: number - name?: ReactNode - meta?: ReactNode -} - -export const BundleBar: FC = ({ - chips = [], - segments = 12, - filledSegments = Math.floor(segments / 2), - name, - meta, - className, - ...rest -}) => { - const rootClassName = ['component', 'component-bundle-bar', className].filter(Boolean).join(' ') - const totalSegments = Math.max(0, segments) - const filled = Math.max(0, Math.min(totalSegments, filledSegments)) - - return ( -
    - {chips.length > 0 && ( -
    - {chips.map((chip) => ( - - {chip.icon && } - {chip.label} - - ))} -
    - )} - {totalSegments > 0 && ( -
    - {Array.from({ length: totalSegments }).map((_, index) => ( - - ))} -
    - )} - {(name || meta) && ( -
    - {name && {name}} - {meta && {meta}} -
    - )} -
    - ) -} - -BundleBar.displayName = 'BundleBar' diff --git a/react-components/src/Button.tsx b/react-components/src/Button.tsx deleted file mode 100644 index 7c905ad4..00000000 --- a/react-components/src/Button.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react' - -export interface ButtonProps extends React.ButtonHTMLAttributes { - children?: React.ReactNode - /** @deprecated Use `children` instead. */ - label?: string - outline?: boolean - size?: 'small' | 'default' | 'large' - variant?: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'danger' | 'link' - /** Shows a spinner, disables the button, and sets `aria-busy`. */ - loading?: boolean - /** Stretches the button to the full width of its container. */ - fullWidth?: boolean - /** Icon rendered before the label. */ - startIcon?: React.ReactNode - /** Icon rendered after the label. */ - endIcon?: React.ReactNode -} - -export const Button = React.forwardRef(( - { - children, - label, - outline = false, - size = 'default', - variant = 'primary', - loading = false, - fullWidth = false, - startIcon, - endIcon, - className, - disabled, - type, - ...props - }, - ref, -) => { - const buttonClass = [ - 'btn', - variant === 'link' ? 'btn-link' : `btn${outline ? '-outline' : ''}-${variant}`, - size === 'large' && 'btn-lg', - size === 'small' && 'btn-sm', - fullWidth && 'w-100', - 'd-inline-flex align-items-center justify-content-center gap-2', - className, - ].filter(Boolean).join(' ') - - const content = children ?? label - - return ( - - ) -}) - -Button.displayName = 'Button' diff --git a/react-components/src/CalloutQuote.tsx b/react-components/src/CalloutQuote.tsx deleted file mode 100644 index 5c6997bc..00000000 --- a/react-components/src/CalloutQuote.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react' -import { Icon } from './Icon' - -export interface CalloutQuoteProps extends React.HTMLAttributes { - quote: React.ReactNode - attribution?: string - source?: string - sourceHref?: string -} - -export const CalloutQuote: React.FC = ({ - quote, - attribution, - source, - sourceHref, - className = '', - ...rest -}) => { - const rootClass = `component component-callout-quote ${className}`.trim() - - return ( -
    - -
    {quote}
    - {(attribution || source) && ( -
    - {attribution ? {attribution} : null} - {source ? ( - sourceHref ? ( - - {source} - - ) : ( - {source} - ) - ) : null} -
    - )} -
    - ) -} - -export default CalloutQuote diff --git a/react-components/src/Card.tsx b/react-components/src/Card.tsx deleted file mode 100644 index d5010fdd..00000000 --- a/react-components/src/Card.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import { Skeleton } from './Skeleton' - -export interface CardProps { - children?: React.ReactNode - header?: React.ReactNode - variant?: 'default' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'danger' - className?: string - loading?: boolean -} - -export const Card: React.FC = ({ children, header, variant = 'default', className, loading = false }) => { - const classNameValue = `component component-card card border-1 ${variant !== 'default' ? `bg-${variant} text-white` : ''} ${className || ''}` - if (loading) { - return ( -
    - {header !== undefined && ( -
    - )} -
    - -
    -
    - ) - } - return ( -
    - {header &&
    {header}
    } -
    {children}
    -
    - ) -} diff --git a/react-components/src/CardOptions.tsx b/react-components/src/CardOptions.tsx deleted file mode 100644 index a024612e..00000000 --- a/react-components/src/CardOptions.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react' -import { Icon } from './Icon' - -export interface CardOption { - key: string - icon?: string - imgSrc?: string - title: string - description: string -} - -export interface CardOptionsProps { - options: CardOption[] - value?: string | null - onChange?: (key: string) => void - columns?: number - className?: string -} - -export const CardOptions: React.FC = ({ - options, - value = null, - onChange, - columns, - className = '', -}) => { - const style = columns - ? { gridTemplateColumns: `repeat(${columns}, 1fr)` } as React.CSSProperties - : undefined - - return ( -
    - {options.map((opt) => { - const selected = value === opt.key - return ( - - ) - })} -
    - ) -} diff --git a/react-components/src/Carousel.tsx b/react-components/src/Carousel.tsx deleted file mode 100644 index a6129310..00000000 --- a/react-components/src/Carousel.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useCallback, useEffect, useId, useRef, useState } from 'react' -import { Icon } from './Icon' - -// ── Types ────────────────────────────────────────────────────────────────────── - -export interface CarouselProps extends React.HTMLAttributes { - children: React.ReactNode - autoPlay?: boolean - interval?: number - showDots?: boolean - showArrows?: boolean - loop?: boolean - className?: string -} - -// ── Component ────────────────────────────────────────────────────────────────── - -export const Carousel: React.FC = ({ - children, - autoPlay = false, - interval = 4000, - showDots = true, - showArrows = true, - loop = true, - className = '', - ...rest -}) => { - const genId = useId() - const carouselId = `carousel-${genId.replace(/[^a-zA-Z0-9_-]/g, '')}` - - const slides = React.Children.toArray(children) - const count = slides.length - const [current, setCurrent] = useState(0) - const [paused, setPaused] = useState(false) - const autoRef = useRef | null>(null) - - // Pointer-drag swipe - const startX = useRef(0) - const isDragging = useRef(false) - - const goTo = useCallback((idx: number) => { - setCurrent((loop - ? ((idx % count) + count) % count - : Math.max(0, Math.min(count - 1, idx)) - )) - }, [count, loop]) - - const prev = () => goTo(current - 1) - const next = () => goTo(current + 1) - - // Auto-play - useEffect(() => { - if (!autoPlay || paused || count <= 1) return - autoRef.current = setInterval(next, interval) - return () => { if (autoRef.current) clearInterval(autoRef.current) } - }, [autoPlay, paused, interval, current, count]) - - // Keyboard - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowLeft') prev() - if (e.key === 'ArrowRight') next() - } - - // Touch / pointer swipe - const handlePointerDown = (e: React.PointerEvent) => { - isDragging.current = true - startX.current = e.clientX - } - const handlePointerUp = (e: React.PointerEvent) => { - if (!isDragging.current) return - isDragging.current = false - const diff = e.clientX - startX.current - if (Math.abs(diff) > 40) { - if (diff < 0) next(); else prev() - } - } - - const rootClass = [ - 'component component-carousel', - className, - ].filter(Boolean).join(' ') - - return ( -
    setPaused(true)} - onMouseLeave={() => setPaused(false)} - onKeyDown={handleKeyDown} - tabIndex={0} - {...rest} - > - {/* Slides */} -
    - {slides.map((slide, idx) => ( -
    - {slide} -
    - ))} -
    - - {/* Arrows */} - {showArrows && count > 1 && ( - <> - - - - )} - - {/* Dots */} - {showDots && count > 1 && ( -
    - {slides.map((_, idx) => ( -
    - )} -
    - ) -} diff --git a/react-components/src/CdnMap.tsx b/react-components/src/CdnMap.tsx deleted file mode 100644 index 281ae43f..00000000 --- a/react-components/src/CdnMap.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { FC, HTMLAttributes } from 'react' - -export interface CdnMapNode { - top: string - left: string - variant?: 'primary' | 'accent' - label?: string -} - -export interface CdnMapProps extends HTMLAttributes { - nodes: CdnMapNode[] - height?: number | string -} - -export const CdnMap: FC = ({ nodes, height = 160, className, style, ...rest }) => { - const rootClassName = ['component', 'component-cdn-map', className].filter(Boolean).join(' ') - return ( -
    - - ) -} - -CdnMap.displayName = 'CdnMap' diff --git a/react-components/src/Changelog.tsx b/react-components/src/Changelog.tsx deleted file mode 100644 index 613cc5d4..00000000 --- a/react-components/src/Changelog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { FC, HTMLAttributes } from 'react' -import { Tag } from './Tag' -import { Skeleton } from './Skeleton' - -export interface ChangelogEntry { - id?: string | number - date: string - title: string - description: string - tag?: string -} - -export interface ChangelogProps extends HTMLAttributes { - entries: ChangelogEntry[] - maxVisible?: number - readMoreHref: string - readMoreLabel?: string - loading?: boolean -} - -export const Changelog: FC = ({ - entries, - maxVisible = 5, - readMoreHref, - readMoreLabel = 'View full changelog', - loading = false, - className, - ...rest -}) => { - const visibleEntries = entries.slice(0, maxVisible) - - const rootClassName = ['component component-changelog', className].filter(Boolean).join(' ') - - if (!loading && visibleEntries.length === 0) { - return null - } - - return ( -
    -
    -
    - {loading ? ( -
    - - -
    - ) : ( -
    -

    Latest updates

    -

    Product changelog

    -

    - Stay up to date with the most recent improvements, fixes, and new features. -

    -
    - )} - {!loading && ( - - {readMoreLabel} - - )} -
    - -
      - {loading ? ( - Array.from({ length: maxVisible }, (_, i) => ( -
    1. -
      - - - -
      -
    2. - )) - ) : visibleEntries.map((entry, index) => ( -
    3. -
      -
      - {entry.date} - {entry.tag && {entry.tag}} -
      -

      {entry.title}

      -

      {entry.description}

      -
      -
    4. - ))} -
    -
    -
    - ) -} - -export default Changelog diff --git a/react-components/src/Chart/AreaChart.tsx b/react-components/src/Chart/AreaChart.tsx deleted file mode 100644 index a681487a..00000000 --- a/react-components/src/Chart/AreaChart.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import React, { useState } from 'react' -import { Skeleton } from '../Skeleton' -import type { LineChartSeries } from './LineChart' - -export type { LineChartSeries as AreaChartSeries } - -export interface AreaChartProps { - series: LineChartSeries[] - title?: string - subtitle?: string - height?: number - stacked?: boolean - showGrid?: boolean - showLegend?: boolean - xFormatter?: (v: string | number) => string - yFormatter?: (v: number) => string - loading?: boolean - className?: string -} - -const PALETTE = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'] - -interface TooltipState { cx: number; cy: number; text: string } - -const niceMax = (v: number): number => { - if (v <= 0) return 10 - const e = Math.pow(10, Math.floor(Math.log10(v))) - return Math.ceil(v / e) * e -} - -const defaultFmt = (v: number): string => { - if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M` - if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k` - return String(Math.round(v)) -} - -export const AreaChart: React.FC = ({ - series, - title, - subtitle, - height = 260, - showGrid = true, - showLegend = true, - xFormatter, - yFormatter, - loading = false, - className = '', -}) => { - const [tooltip, setTooltip] = useState(null) - const fmt = yFormatter ?? defaultFmt - - if (loading) { - return ( -
    - {title &&
    } - -
    - ) - } - - if (!series?.length) { - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} -
    No data
    -
    - ) - } - - const allX = Array.from(new Set(series.flatMap(s => s.data.map(p => String(p.x))))) - const allYVals = series.flatMap(s => s.data.map(p => p.y)) - const yMax = niceMax(Math.max(...allYVals) * 1.1) - - const VW = 560, VH = height - const PL = 52, PR = 20, PT = 18, PB = 40 - const cW = VW - PL - PR - const cH = VH - PT - PB - - const YTICKS = 5 - const yTicks = Array.from({ length: YTICKS + 1 }, (_, i) => (yMax / YTICKS) * i) - - const xPos = (xVal: string | number): number => { - const idx = allX.indexOf(String(xVal)) - if (allX.length <= 1) return PL + cW / 2 - return PL + (idx / (allX.length - 1)) * cW - } - const yPos = (yVal: number): number => PT + cH - (yVal / yMax) * cH - const baseline = PT + cH - - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} - setTooltip(null)} - > - {/* Grid + Y labels */} - {showGrid && yTicks.map((t, i) => { - const y = yPos(t) - return ( - - - {fmt(t)} - - ) - })} - {/* X labels */} - {allX.map((x, i) => { - const skip = Math.ceil(allX.length / 10) - if (i % skip !== 0 && i !== allX.length - 1) return null - return ( - - {xFormatter ? xFormatter(x) : x} - - ) - })} - {/* Axes */} - - - {/* Area fills (rendered below lines) */} - {series.map((s, si) => { - const color = s.color ?? PALETTE[si % PALETTE.length] - const pts = s.data.filter(p => allX.includes(String(p.x))) - if (!pts.length) return null - const first = pts[0], last = pts[pts.length - 1] - const areaD = [ - `M ${xPos(first.x).toFixed(1)} ${baseline.toFixed(1)}`, - ...pts.map(p => `L ${xPos(p.x).toFixed(1)} ${yPos(p.y).toFixed(1)}`), - `L ${xPos(last.x).toFixed(1)} ${baseline.toFixed(1)}`, - 'Z', - ].join(' ') - return - })} - {/* Lines + dots */} - {series.map((s, si) => { - const color = s.color ?? PALETTE[si % PALETTE.length] - const pts = s.data.filter(p => allX.includes(String(p.x))) - if (!pts.length) return null - const d = pts.map((p, pi) => - `${pi === 0 ? 'M' : 'L'} ${xPos(p.x).toFixed(1)} ${yPos(p.y).toFixed(1)}` - ).join(' ') - return ( - - - {pts.map((p, pi) => ( - setTooltip({ - cx: xPos(p.x), - cy: yPos(p.y) - 14, - text: `${s.label}: ${fmt(p.y)}`, - })} - /> - ))} - - ) - })} - {/* Tooltip */} - {tooltip && ( - - - {tooltip.text} - - )} - - {showLegend && series.length > 1 && ( -
    - {series.map((s, si) => ( -
    - - {s.label} -
    - ))} -
    - )} -
    - ) -} diff --git a/react-components/src/Chart/BarChart.tsx b/react-components/src/Chart/BarChart.tsx deleted file mode 100644 index 9497e4a9..00000000 --- a/react-components/src/Chart/BarChart.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import React, { useState } from 'react' -import { Skeleton } from '../Skeleton' - -export interface BarChartDataItem { - label: string - value: number - color?: string -} - -export interface BarChartProps { - data: BarChartDataItem[] - title?: string - subtitle?: string - orientation?: 'vertical' | 'horizontal' - height?: number - showValues?: boolean - yFormatter?: (v: number) => string - onClick?: (item: BarChartDataItem, index: number) => void - loading?: boolean - className?: string -} - -const PALETTE = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'] - -interface TooltipState { cx: number; cy: number; text: string } - -const niceMax = (v: number): number => { - if (v <= 0) return 10 - const e = Math.pow(10, Math.floor(Math.log10(v))) - return Math.ceil(v / e) * e -} - -const defaultFmt = (v: number): string => { - if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M` - if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k` - return String(Math.round(v)) -} - -export const BarChart: React.FC = ({ - data, - title, - subtitle, - orientation = 'vertical', - height = 260, - showValues = false, - yFormatter, - onClick, - loading = false, - className = '', -}) => { - const [tooltip, setTooltip] = useState(null) - const fmt = yFormatter ?? defaultFmt - - if (loading) { - return ( -
    - {title &&
    } - -
    - ) - } - - if (!data?.length) { - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} -
    No data
    -
    - ) - } - - const VW = 560 - const VH = height - const PL = 52, PR = 12, PT = 18, PB = 38 - const cW = VW - PL - PR - const cH = VH - PT - PB - const maxVal = niceMax(Math.max(...data.map(d => d.value))) - const TICKS = 5 - const ticks = Array.from({ length: TICKS + 1 }, (_, i) => (maxVal / TICKS) * i) - - if (orientation === 'vertical') { - const bGroup = cW / data.length - const bW = bGroup * 0.65 - const bOff = bGroup * 0.175 - - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} - setTooltip(null)} - > - {ticks.map((t, i) => { - const y = PT + cH - (t / maxVal) * cH - return ( - - - {fmt(t)} - - ) - })} - - {data.map((d, i) => { - const bH = (d.value / maxVal) * cH - const x = PL + i * bGroup + bOff - const y = PT + cH - bH - const color = d.color ?? PALETTE[i % PALETTE.length] - return ( - onClick?.(d, i)} - onMouseEnter={() => setTooltip({ cx: x + bW / 2, cy: y - 12, text: `${d.label}: ${fmt(d.value)}` })} - > - - {showValues && ( - - {fmt(d.value)} - - )} - {d.label} - - ) - })} - {tooltip && ( - - - {tooltip.text} - - )} - -
    - ) - } - - // Horizontal orientation - const bGroup = cH / data.length - const bH2 = bGroup * 0.65 - const bOff2 = bGroup * 0.175 - const xTicks = Array.from({ length: TICKS + 1 }, (_, i) => (maxVal / TICKS) * i) - - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} - setTooltip(null)} - > - {xTicks.map((t, i) => { - const x = PL + (t / maxVal) * cW - return ( - - - {fmt(t)} - - ) - })} - - {data.map((d, i) => { - const bW2 = (d.value / maxVal) * cW - const y = PT + i * bGroup + bOff2 - const color = d.color ?? PALETTE[i % PALETTE.length] - return ( - onClick?.(d, i)} - onMouseEnter={() => setTooltip({ cx: PL + bW2 + 4, cy: y + bH2 / 2, text: `${d.label}: ${fmt(d.value)}` })} - > - - {showValues && ( - - {fmt(d.value)} - - )} - {d.label} - - ) - })} - {tooltip && ( - - - {tooltip.text} - - )} - -
    - ) -} diff --git a/react-components/src/Chart/ChartContainer.tsx b/react-components/src/Chart/ChartContainer.tsx deleted file mode 100644 index c04acada..00000000 --- a/react-components/src/Chart/ChartContainer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' -import { Spinner } from '../Spinner' - -export interface ChartContainerProps { - title?: string - subtitle?: string - children?: React.ReactNode - legend?: React.ReactNode - actions?: React.ReactNode - loading?: boolean - empty?: boolean - emptySlot?: React.ReactNode - className?: string -} - -export const ChartContainer: React.FC = ({ - title, - subtitle, - children, - legend, - actions, - loading = false, - empty = false, - emptySlot, - className = '', -}) => { - const hasHeader = title || subtitle || actions - - return ( -
    - {hasHeader && ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} - {actions && ( -
    {actions}
    - )} -
    - )} -
    - {loading ? ( -
    - -
    - ) : empty ? ( -
    - {emptySlot ?? No data available} -
    - ) : ( - children - )} -
    - {legend && ( -
    {legend}
    - )} -
    - ) -} diff --git a/react-components/src/Chart/FunnelChart.tsx b/react-components/src/Chart/FunnelChart.tsx deleted file mode 100644 index 648af74c..00000000 --- a/react-components/src/Chart/FunnelChart.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import React, { useState } from 'react' -import { Skeleton } from '../Skeleton' - -export interface FunnelStep { - label: string - value: number - color?: string -} - -export interface FunnelChartProps { - data: FunnelStep[] - title?: string - subtitle?: string - height?: number - showLabels?: boolean - loading?: boolean - onClick?: (step: FunnelStep, index: number) => void - className?: string -} - -// Saturated indigo→fuchsia descent. Every shade stays dark enough for white -// labels to read (the old palette faded to near-white = invisible text). -const PALETTE = ['#4f46e5', '#6d28d9', '#7c3aed', '#9333ea', '#a21caf', '#be185d', '#db2777'] - -export const FunnelChart: React.FC = ({ - data, - title, - subtitle, - height = 300, - showLabels = true, - loading = false, - onClick, - className = '', -}) => { - const [activeIndex, setActiveIndex] = useState(null) - - if (loading) { - return ( -
    - {title &&
    } - -
    - ) - } - - if (!data?.length) { - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} -
    No data
    -
    - ) - } - - const maxVal = Math.max(...data.map(d => d.value)) - const VW = 560 - const VH = height - const stepH = VH / data.length - const maxW = VW * 0.88 - const cx = VW / 2 - const GAP = 2 - - const steps = data.map((d, i) => { - const w = (d.value / maxVal) * maxW - const prevW = i === 0 ? maxW : (data[i - 1].value / maxVal) * maxW - const color = d.color ?? PALETTE[i % PALETTE.length] - const y = i * stepH - const pct = data[0].value > 0 ? Math.round((d.value / data[0].value) * 100) : 100 - const topL = cx - prevW / 2 - const topR = cx + prevW / 2 - const botL = cx - w / 2 - const botR = cx + w / 2 - const yTop = y + GAP - const yBot = y + stepH - GAP - const points = `${topL.toFixed(1)},${yTop.toFixed(1)} ${topR.toFixed(1)},${yTop.toFixed(1)} ${botR.toFixed(1)},${yBot.toFixed(1)} ${botL.toFixed(1)},${yBot.toFixed(1)}` - return { ...d, color, pct, points, midY: y + stepH / 2 } - }) - - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} - setActiveIndex(null)} - > - {steps.map((s, i) => ( - onClick?.(s, i)} - onMouseEnter={() => setActiveIndex(i)} - > - - {showLabels && ( - <> - - {s.label} - - - {s.value.toLocaleString()}{i > 0 ? ` (${s.pct}%)` : ''} - - - )} - - ))} - -
    - ) -} diff --git a/react-components/src/Chart/GanttChart.tsx b/react-components/src/Chart/GanttChart.tsx deleted file mode 100644 index feed091b..00000000 --- a/react-components/src/Chart/GanttChart.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React, { useMemo } from 'react' -import { Skeleton } from '../Skeleton' - -export interface GanttTask { - id: string - label: string - start: string - end: string - color?: string - progress?: number - dependencies?: string[] -} - -export interface GanttChartProps { - tasks: GanttTask[] - title?: string - subtitle?: string - startDate?: string - endDate?: string - loading?: boolean - onTaskClick?: (task: GanttTask) => void - className?: string -} - -const PALETTE = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'] - -const LABEL_W = 130 -const ROW_H = 36 -const HEADER_H = 32 -const VW = 700 -const CHART_W = VW - LABEL_W -const DAY_MS = 86_400_000 - -export const GanttChart: React.FC = ({ - tasks, - title, - subtitle, - startDate, - endDate, - loading = false, - onTaskClick, - className = '', -}) => { - const { markers, resolvedTasks, svgH } = useMemo(() => { - if (!tasks.length) return { markers: [], resolvedTasks: [], svgH: HEADER_H + 10 } - - const starts = tasks.map(t => new Date(t.start).getTime()) - const ends = tasks.map(t => new Date(t.end).getTime()) - const ts = startDate ? new Date(startDate).getTime() : Math.min(...starts) - const te = endDate ? new Date(endDate).getTime() : Math.max(...ends) - const totalMs = te - ts || DAY_MS - - const xFor = (ts2: number): number => LABEL_W + ((ts2 - ts) / totalMs) * CHART_W - - const totalDays = Math.ceil(totalMs / DAY_MS) - const stepDays = Math.max(1, Math.ceil(totalDays / 8)) - const mks: { x: number; label: string }[] = [] - for (let d = 0; d <= totalDays; d += stepDays) { - const dt = new Date(ts + d * DAY_MS) - mks.push({ - x: LABEL_W + (d / totalDays) * CHART_W, - label: `${dt.getMonth() + 1}/${dt.getDate()}`, - }) - } - - const rt = tasks.map((t, i) => { - const s = new Date(t.start).getTime() - const e = new Date(t.end).getTime() - return { - ...t, - color: t.color ?? PALETTE[i % PALETTE.length], - x: xFor(s), - x2: xFor(e), - y: HEADER_H + i * ROW_H, - } - }) - - return { - markers: mks, - resolvedTasks: rt, - svgH: HEADER_H + tasks.length * ROW_H + 8, - } - }, [tasks, startDate, endDate]) - - if (loading) { - return ( -
    - {title &&
    } - -
    - ) - } - - if (!tasks?.length) { - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} -
    No tasks
    -
    - ) - } - - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} -
    - - {/* Alternating row stripes */} - {resolvedTasks.map((t, i) => ( - - ))} - {/* Header background */} - - {/* Date markers */} - {markers.map((m, i) => ( - - - {m.label} - - ))} - {/* Task rows */} - {resolvedTasks.map((t, i) => { - const barW = Math.max(t.x2 - t.x, 4) - const barH = ROW_H * 0.5 - const barY = t.y + ROW_H * 0.25 - return ( - onTaskClick?.(t)} - aria-label={t.label} - > - {/* Row label */} - - {t.label.length > 14 ? `${t.label.slice(0, 13)}…` : t.label} - - {/* Task bar */} - {t.progress !== undefined ? ( - <> - - - - ) : ( - - )} - - ) - })} - {/* Left border */} - - -
    -
    - ) -} diff --git a/react-components/src/Chart/Heatmap.tsx b/react-components/src/Chart/Heatmap.tsx deleted file mode 100644 index eac06422..00000000 --- a/react-components/src/Chart/Heatmap.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import React, { useState } from 'react' -import { Skeleton } from '../Skeleton' - -export interface HeatmapCell { - row: string | number - col: string | number - value: number -} - -export interface HeatmapProps { - data: HeatmapCell[] - rows: (string | number)[] - cols: (string | number)[] - title?: string - subtitle?: string - colorScale?: string[] - cellSize?: number - loading?: boolean - className?: string -} - -const DEFAULT_SCALE = ['#f1f5f9', '#bfdbfe', '#93c5fd', '#60a5fa', '#3b82f6', '#2563eb', '#1d4ed8'] - -const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { - const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) - return r ? { r: parseInt(r[1], 16), g: parseInt(r[2], 16), b: parseInt(r[3], 16) } : null -} - -const lerp = (a: number, b: number, t: number) => a + (b - a) * t - -const interpolateColor = (scale: string[], t: number): string => { - if (t <= 0) return scale[0] - if (t >= 1) return scale[scale.length - 1] - const seg = (scale.length - 1) * t - const lo = Math.floor(seg) - const hi = Math.ceil(seg) - const f = seg - lo - const c1 = hexToRgb(scale[lo]) - const c2 = hexToRgb(scale[hi]) - if (!c1 || !c2) return scale[lo] - return `rgb(${Math.round(lerp(c1.r, c2.r, f))},${Math.round(lerp(c1.g, c2.g, f))},${Math.round(lerp(c1.b, c2.b, f))})` -} - -export const Heatmap: React.FC = ({ - data, - rows, - cols, - title, - subtitle, - colorScale = DEFAULT_SCALE, - cellSize = 28, - loading = false, - className = '', -}) => { - const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null) - - if (loading) { - return ( -
    - {title &&
    } - -
    - ) - } - - const allVals = data.map(d => d.value) - const minVal = Math.min(...allVals) - const maxVal = Math.max(...allVals) - const range = maxVal - minVal || 1 - - const LABEL_W = 80 - const HEADER_H = 22 - const GAP = 2 - const svgW = LABEL_W + cols.length * (cellSize + GAP) + 20 - const svgH = HEADER_H + rows.length * (cellSize + GAP) + 10 - - const cellMap = new Map(data.map(d => [`${d.row}::${d.col}`, d.value])) - - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} -
    - setTooltip(null)} - > - {/* Column headers */} - {cols.map((col, ci) => ( - - {String(col)} - - ))} - {/* Rows */} - {rows.map((row, ri) => ( - - - {String(row)} - - {cols.map((col, ci) => { - const v = cellMap.get(`${row}::${col}`) - const t = v !== undefined ? (v - minVal) / range : -1 - const fill = t >= 0 ? interpolateColor(colorScale, t) : '#f8fafc' - const cx = LABEL_W + ci * (cellSize + GAP) - const cy = HEADER_H + ri * (cellSize + GAP) - return ( - v !== undefined && setTooltip({ - x: cx + cellSize / 2, - y: cy - 8, - text: `${row} / ${col}: ${v}`, - })} - /> - ) - })} - - ))} - {/* Tooltip */} - {tooltip && ( - - - {tooltip.text} - - )} - -
    -
    - ) -} diff --git a/react-components/src/Chart/LineChart.tsx b/react-components/src/Chart/LineChart.tsx deleted file mode 100644 index 045278b0..00000000 --- a/react-components/src/Chart/LineChart.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import React, { useState } from 'react' -import { Skeleton } from '../Skeleton' - -export interface LineChartPoint { - x: string | number - y: number -} - -export interface LineChartSeries { - label: string - data: LineChartPoint[] - color?: string -} - -export interface LineChartProps { - series: LineChartSeries[] - title?: string - subtitle?: string - height?: number - showGrid?: boolean - showLegend?: boolean - xFormatter?: (v: string | number) => string - yFormatter?: (v: number) => string - loading?: boolean - className?: string -} - -const PALETTE = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'] - -interface TooltipState { cx: number; cy: number; text: string } - -const niceMax = (v: number): number => { - if (v <= 0) return 10 - const e = Math.pow(10, Math.floor(Math.log10(v))) - return Math.ceil(v / e) * e -} - -const defaultFmt = (v: number): string => { - if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M` - if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k` - return String(Math.round(v)) -} - -export const LineChart: React.FC = ({ - series, - title, - subtitle, - height = 260, - showGrid = true, - showLegend = true, - xFormatter, - yFormatter, - loading = false, - className = '', -}) => { - const [tooltip, setTooltip] = useState(null) - const fmt = yFormatter ?? defaultFmt - - if (loading) { - return ( -
    - {title &&
    } - -
    - ) - } - - if (!series?.length) { - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} -
    No data
    -
    - ) - } - - const allX = Array.from(new Set(series.flatMap(s => s.data.map(p => String(p.x))))) - const allYVals = series.flatMap(s => s.data.map(p => p.y)) - const yMax = niceMax(Math.max(...allYVals) * 1.05) - - const VW = 560, VH = height - const PL = 52, PR = 20, PT = 18, PB = 40 - const cW = VW - PL - PR - const cH = VH - PT - PB - - const YTICKS = 5 - const yTicks = Array.from({ length: YTICKS + 1 }, (_, i) => (yMax / YTICKS) * i) - - const xPos = (xVal: string | number): number => { - const idx = allX.indexOf(String(xVal)) - if (allX.length <= 1) return PL + cW / 2 - return PL + (idx / (allX.length - 1)) * cW - } - const yPos = (yVal: number): number => PT + cH - (yVal / yMax) * cH - - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} - setTooltip(null)} - > - {/* Grid + Y labels */} - {showGrid && yTicks.map((t, i) => { - const y = yPos(t) - return ( - - - {fmt(t)} - - ) - })} - {/* X labels */} - {allX.map((x, i) => { - const skip = Math.ceil(allX.length / 10) - if (i % skip !== 0 && i !== allX.length - 1) return null - return ( - - {xFormatter ? xFormatter(x) : x} - - ) - })} - {/* Axes */} - - - {/* Series */} - {series.map((s, si) => { - const color = s.color ?? PALETTE[si % PALETTE.length] - const pts = s.data.filter(p => allX.includes(String(p.x))) - if (!pts.length) return null - const d = pts.map((p, pi) => - `${pi === 0 ? 'M' : 'L'} ${xPos(p.x).toFixed(1)} ${yPos(p.y).toFixed(1)}` - ).join(' ') - return ( - - - {pts.map((p, pi) => ( - setTooltip({ - cx: xPos(p.x), - cy: yPos(p.y) - 14, - text: `${s.label}: ${fmt(p.y)}`, - })} - /> - ))} - - ) - })} - {/* Tooltip */} - {tooltip && ( - - - {tooltip.text} - - )} - - {showLegend && series.length > 1 && ( -
    - {series.map((s, si) => ( -
    - - {s.label} -
    - ))} -
    - )} -
    - ) -} diff --git a/react-components/src/Chart/PieChart.tsx b/react-components/src/Chart/PieChart.tsx deleted file mode 100644 index ac837a88..00000000 --- a/react-components/src/Chart/PieChart.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useState } from 'react' -import { Skeleton } from '../Skeleton' - -export interface PieChartSlice { - label: string - value: number - color?: string -} - -export interface PieChartProps { - data: PieChartSlice[] - title?: string - subtitle?: string - donut?: boolean - centerLabel?: string - showLegend?: boolean - height?: number - loading?: boolean - className?: string -} - -const PALETTE = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'] - -const polarToCart = (cx: number, cy: number, r: number, angle: number) => ({ - x: cx + r * Math.cos(angle), - y: cy + r * Math.sin(angle), -}) - -const arcPath = ( - cx: number, cy: number, - r: number, ir: number, - startAngle: number, endAngle: number, - donut: boolean, -): string => { - const s = polarToCart(cx, cy, r, startAngle) - const e = polarToCart(cx, cy, r, endAngle) - const large = endAngle - startAngle > Math.PI ? 1 : 0 - - if (donut) { - const is = polarToCart(cx, cy, ir, startAngle) - const ie = polarToCart(cx, cy, ir, endAngle) - return [ - `M ${s.x.toFixed(2)} ${s.y.toFixed(2)}`, - `A ${r} ${r} 0 ${large} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`, - `L ${ie.x.toFixed(2)} ${ie.y.toFixed(2)}`, - `A ${ir} ${ir} 0 ${large} 0 ${is.x.toFixed(2)} ${is.y.toFixed(2)}`, - 'Z', - ].join(' ') - } - return [ - `M ${cx} ${cy}`, - `L ${s.x.toFixed(2)} ${s.y.toFixed(2)}`, - `A ${r} ${r} 0 ${large} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`, - 'Z', - ].join(' ') -} - -export const PieChart: React.FC = ({ - data, - title, - subtitle, - donut = false, - centerLabel, - showLegend = true, - height = 260, - loading = false, - className = '', -}) => { - const [activeIndex, setActiveIndex] = useState(null) - - if (loading) { - return ( -
    - {title &&
    } -
    - -
    -
    - ) - } - - if (!data?.length) { - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} -
    No data
    -
    - ) - } - - const total = data.reduce((s, d) => s + d.value, 0) - const size = Math.min(height, 260) - const cx = size / 2 - const cy = size / 2 - const r = size / 2 - 12 - const ir = donut ? r * 0.55 : 0 - - let currentAngle = -Math.PI / 2 - const slices = data.map((d, i) => { - const angle = (d.value / total) * 2 * Math.PI - const startAngle = currentAngle - currentAngle += angle - return { - ...d, - color: d.color ?? PALETTE[i % PALETTE.length], - startAngle, - endAngle: currentAngle, - pct: Math.round((d.value / total) * 100), - } - }) - - const activeSlice = activeIndex !== null ? slices[activeIndex] : null - - return ( -
    - {(title || subtitle) && ( -
    - {title &&
    {title}
    } - {subtitle &&
    {subtitle}
    } -
    - )} -
    - setActiveIndex(null)} - style={{ flexShrink: 0 }} - > - {slices.map((s, i) => { - const isActive = activeIndex === i - const scale = isActive - ? `translate(${cx}, ${cy}) scale(1.06) translate(-${cx}, -${cy})` - : undefined - return ( - setActiveIndex(i)} - aria-label={`${s.label}: ${s.pct}%`} - /> - ) - })} - {donut && ( - <> - - - {activeSlice ? activeSlice.label : (centerLabel ?? '')} - - - {activeSlice ? `${activeSlice.pct}%` : `${total}`} - - - )} - - {showLegend && ( -
    - {slices.map((s, i) => ( -
    setActiveIndex(i)} - onMouseLeave={() => setActiveIndex(null)} - style={{ cursor: 'pointer' }} - > - - {s.label} - {s.pct}% -
    - ))} -
    - )} -
    -
    - ) -} diff --git a/react-components/src/Chart/Sparkline.tsx b/react-components/src/Chart/Sparkline.tsx deleted file mode 100644 index 80a60b5f..00000000 --- a/react-components/src/Chart/Sparkline.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react' - -export interface SparklineProps { - data: number[] - type?: 'line' | 'bar' - color?: string - height?: number - width?: number - className?: string -} - -export const Sparkline: React.FC = ({ - data, - type = 'line', - color = '#6366f1', - height = 32, - width = 120, - className = '', -}) => { - if (!data || data.length === 0) return null - - const min = Math.min(...data) - const max = Math.max(...data) - const range = max - min || 1 - const scaleY = (v: number) => height - 2 - ((v - min) / range) * (height - 4) - - if (type === 'line') { - const d = data.map((v, i) => { - const x = (data.length === 1 ? width / 2 : (i / (data.length - 1)) * width).toFixed(2) - const y = scaleY(v).toFixed(2) - return `${i === 0 ? 'M' : 'L'} ${x} ${y}` - }).join(' ') - - return ( - - ) - } - - const bw = width / data.length - return ( - - ) -} diff --git a/react-components/src/Chart/TrendIndicator.tsx b/react-components/src/Chart/TrendIndicator.tsx deleted file mode 100644 index 74b25dd9..00000000 --- a/react-components/src/Chart/TrendIndicator.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' - -export interface TrendIndicatorProps { - value: number | string - direction?: 'up' | 'down' | 'neutral' - size?: 'small' | 'default' | 'large' - className?: string -} - -export const TrendIndicator: React.FC = ({ - value, - direction, - size = 'default', - className = '', -}) => { - const computed: 'up' | 'down' | 'neutral' = direction ?? ( - typeof value === 'number' - ? value > 0 ? 'up' : value < 0 ? 'down' : 'neutral' - : 'neutral' - ) - - const displayValue = typeof value === 'number' - ? `${value > 0 ? '+' : ''}${value}%` - : value - - const iconName = - computed === 'up' ? 'arrow-up-short' : - computed === 'down' ? 'arrow-down-short' : - 'dash' - - return ( - -
    Version - {p} -
    - {v} - - -