@plannotator/ui 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/assets/diff-options.png +0 -0
- package/assets/icon-codex.png +0 -0
- package/assets/look-flat.png +0 -0
- package/assets/look-grid.png +0 -0
- package/assets/review-sections.png +0 -0
- package/assets/review-tree.png +0 -0
- package/assets/workspaces.webp +0 -0
- package/components/AISettingsTab.tsx +145 -0
- package/components/ActionMenu.tsx +101 -0
- package/components/AgentControls.tsx +216 -0
- package/components/AgentsTab.tsx +1294 -0
- package/components/AnnotationPanel.tsx +731 -0
- package/components/AnnotationSidebar.tsx +86 -0
- package/components/AnnotationToolbar.tsx +323 -0
- package/components/AnnotationToolstrip.tsx +355 -0
- package/components/ApproveDropdown.tsx +170 -0
- package/components/AttachmentsButton.tsx +410 -0
- package/components/BlockRenderer.tsx +163 -0
- package/components/BorderTrail.tsx +38 -0
- package/components/CodeFilePicker.tsx +64 -0
- package/components/CodeFilePopout.tsx +602 -0
- package/components/CodePathValidationContext.tsx +14 -0
- package/components/CommentPopover.tsx +512 -0
- package/components/CompletionOverlay.tsx +99 -0
- package/components/ConfirmDialog.tsx +128 -0
- package/components/DocBadges.tsx +231 -0
- package/components/EditorAnnotationCard.tsx +65 -0
- package/components/ExportModal.tsx +565 -0
- package/components/FloatingQuickLabelPicker.tsx +144 -0
- package/components/GitHubIcon.tsx +16 -0
- package/components/GitLabIcon.tsx +30 -0
- package/components/GraphvizBlock.tsx +511 -0
- package/components/ImageAnnotator/Canvas.tsx +118 -0
- package/components/ImageAnnotator/Toolbar.tsx +218 -0
- package/components/ImageAnnotator/index.tsx +272 -0
- package/components/ImageAnnotator/types.ts +39 -0
- package/components/ImageAnnotator/utils.ts +147 -0
- package/components/ImageThumbnail.tsx +126 -0
- package/components/ImportModal.tsx +146 -0
- package/components/InlineMarkdown.tsx +1094 -0
- package/components/KeyboardShortcuts.tsx +208 -0
- package/components/Landing.tsx +496 -0
- package/components/ListItemBody.tsx +62 -0
- package/components/ListMarker.tsx +73 -0
- package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
- package/components/MarkdownEditor.tsx +44 -0
- package/components/MenuVersionSection.tsx +91 -0
- package/components/MermaidBlock.tsx +579 -0
- package/components/ModeToggle.tsx +68 -0
- package/components/OpenInAppButton.tsx +299 -0
- package/components/OverlayScrollArea.tsx +82 -0
- package/components/PermissionModeSetup.tsx +92 -0
- package/components/PinpointOverlay.tsx +99 -0
- package/components/PlanAIAnnouncementDialog.tsx +187 -0
- package/components/PlanHeaderMenu.tsx +299 -0
- package/components/PopoutDialog.tsx +91 -0
- package/components/Popover.tsx +27 -0
- package/components/ProviderIcons.tsx +51 -0
- package/components/PullRequestIcon.tsx +12 -0
- package/components/QuickLabelDropdown.tsx +63 -0
- package/components/RenderedMarkdown.tsx +54 -0
- package/components/RepoIcon.tsx +12 -0
- package/components/ResizeHandle.tsx +86 -0
- package/components/ReviewAgentsIcon.tsx +18 -0
- package/components/SearchableSelect.tsx +157 -0
- package/components/Settings.tsx +2220 -0
- package/components/SparklesIcon.tsx +45 -0
- package/components/StickyHeaderLane.tsx +260 -0
- package/components/TableOfContents.tsx +157 -0
- package/components/TaterSpritePullup.tsx +34 -0
- package/components/TaterSpriteRunning.tsx +54 -0
- package/components/TaterSpriteSitting.tsx +37 -0
- package/components/TextShimmer.tsx +57 -0
- package/components/ThemeProvider.tsx +162 -0
- package/components/ThemeTab.tsx +139 -0
- package/components/ToolbarButtons.tsx +116 -0
- package/components/Tooltip.tsx +45 -0
- package/components/Viewer.tsx +977 -0
- package/components/ai/AIProviderBar.tsx +95 -0
- package/components/ai/DocumentAIChatPanel.tsx +313 -0
- package/components/blocks/AlertBlock.tsx +58 -0
- package/components/blocks/Callout.tsx +60 -0
- package/components/blocks/CodeBlock.tsx +75 -0
- package/components/blocks/HtmlBlock.tsx +122 -0
- package/components/blocks/MathBlock.tsx +36 -0
- package/components/blocks/TableBlock.tsx +146 -0
- package/components/blocks/TablePopout.tsx +279 -0
- package/components/blocks/TableToolbar.tsx +153 -0
- package/components/blocks/proseBody.tsx +102 -0
- package/components/core/button.tsx +44 -0
- package/components/core/textarea.tsx +26 -0
- package/components/diagramLanguages.ts +14 -0
- package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
- package/components/html-viewer/HtmlViewer.tsx +386 -0
- package/components/html-viewer/bridge-script.ts +505 -0
- package/components/html-viewer/index.ts +1 -0
- package/components/html-viewer/useHtmlAnnotation.ts +400 -0
- package/components/icons/AgentIcons.tsx +66 -0
- package/components/icons/AppIcon.tsx +56 -0
- package/components/icons/MessagesIcon.tsx +11 -0
- package/components/icons/ObsidianIcons.tsx +208 -0
- package/components/icons/app/android-studio.svg +369 -0
- package/components/icons/app/antigravity.svg +97 -0
- package/components/icons/app/cursor.svg +16 -0
- package/components/icons/app/file-explorer.svg +20 -0
- package/components/icons/app/finder.png +0 -0
- package/components/icons/app/ghostty.svg +13 -0
- package/components/icons/app/iterm2.svg +13 -0
- package/components/icons/app/powershell.svg +14 -0
- package/components/icons/app/sublime-text.svg +17 -0
- package/components/icons/app/terminal.png +0 -0
- package/components/icons/app/textmate.png +0 -0
- package/components/icons/app/vscode.svg +39 -0
- package/components/icons/app/warp.png +0 -0
- package/components/icons/app/xcode.png +0 -0
- package/components/icons/app/zed-dark.svg +15 -0
- package/components/icons/app/zed.svg +15 -0
- package/components/icons/themeIcons.tsx +47 -0
- package/components/mermaidSvg.ts +33 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
- package/components/plan-diff/PlanDiffBadge.tsx +48 -0
- package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
- package/components/plan-diff/PlanDiffViewer.tsx +214 -0
- package/components/plan-diff/PlanRawDiffView.tsx +102 -0
- package/components/plan-diff/VSCodeIcon.tsx +132 -0
- package/components/settings/HooksTab.tsx +208 -0
- package/components/sidebar/ArchiveBrowser.tsx +98 -0
- package/components/sidebar/CountBadge.tsx +12 -0
- package/components/sidebar/FileBrowser.tsx +498 -0
- package/components/sidebar/MessagesBrowser.tsx +109 -0
- package/components/sidebar/SidebarContainer.tsx +353 -0
- package/components/sidebar/SidebarTabs.tsx +155 -0
- package/components/sidebar/VersionBrowser.tsx +141 -0
- package/components/types.d.ts +7 -0
- package/components/ui/badge.tsx +44 -0
- package/components/ui/button.tsx +83 -0
- package/components/ui/card.tsx +58 -0
- package/components/ui/dialog.tsx +106 -0
- package/components/ui/dropdown-menu.tsx +240 -0
- package/components/ui/state-pill.tsx +45 -0
- package/components/ui/tabs.tsx +46 -0
- package/components/ui/textarea.tsx +25 -0
- package/config/configStore.ts +214 -0
- package/config/index.ts +4 -0
- package/config/reviewView.ts +42 -0
- package/config/settings.ts +310 -0
- package/config/useConfig.ts +20 -0
- package/configure.ts +69 -0
- package/globals.d.ts +14 -0
- package/hooks/pfm/useCodeFilePopout.ts +111 -0
- package/hooks/useAIChat.ts +532 -0
- package/hooks/useAIProviderConfig.ts +115 -0
- package/hooks/useActiveSection.ts +78 -0
- package/hooks/useAgentJobs.ts +306 -0
- package/hooks/useAgentSettings.ts +579 -0
- package/hooks/useAgents.ts +90 -0
- package/hooks/useAnnotationDraft.ts +523 -0
- package/hooks/useAnnotationHighlighter.ts +1170 -0
- package/hooks/useArchive.ts +171 -0
- package/hooks/useAutoClose.ts +104 -0
- package/hooks/useCodeAnnotationDraft.ts +194 -0
- package/hooks/useCodeFilePopout.ts +1 -0
- package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
- package/hooks/useDraggable.ts +108 -0
- package/hooks/useEditorAnnotations.ts +64 -0
- package/hooks/useExternalAnnotationHighlights.ts +105 -0
- package/hooks/useExternalAnnotations.ts +268 -0
- package/hooks/useFileBrowser.ts +402 -0
- package/hooks/useInputMethodSwitch.ts +89 -0
- package/hooks/useIsMobile.ts +17 -0
- package/hooks/useLinkedDoc.ts +494 -0
- package/hooks/useOverlayViewport.ts +36 -0
- package/hooks/usePinpoint.ts +182 -0
- package/hooks/usePlanDiff.ts +176 -0
- package/hooks/usePrintMode.ts +27 -0
- package/hooks/useResizablePanel.ts +173 -0
- package/hooks/useScrollViewport.ts +38 -0
- package/hooks/useSharing.ts +441 -0
- package/hooks/useSidebar.ts +55 -0
- package/hooks/useUpdateCheck.ts +132 -0
- package/hooks/useValidatedCodePaths.ts +94 -0
- package/icons/GitUser.tsx +17 -0
- package/lib/utils.ts +11 -0
- package/package.json +117 -0
- package/plannotator.webp +0 -0
- package/print.css +427 -0
- package/shortcuts/code-review/ai.shortcuts.ts +25 -0
- package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
- package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
- package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
- package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
- package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
- package/shortcuts/core.ts +399 -0
- package/shortcuts/index.ts +21 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
- package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
- package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
- package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
- package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
- package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
- package/shortcuts/runtime.ts +259 -0
- package/sprite_package_additional/index.html +34 -0
- package/sprite_package_additional/sprite.png +0 -0
- package/sprite_package_new/index.html +34 -0
- package/sprite_package_new/sprite.png +0 -0
- package/sprite_package_pulluphang/index.html +34 -0
- package/sprite_package_pulluphang/sprite.png +0 -0
- package/styles.css +1 -0
- package/theme.css +893 -0
- package/themes/adwaita.css +62 -0
- package/themes/andromeeda.css +66 -0
- package/themes/aurora-x.css +66 -0
- package/themes/ayu-dark.css +66 -0
- package/themes/caffeine.css +113 -0
- package/themes/catppuccin.css +62 -0
- package/themes/claude-plus.css +60 -0
- package/themes/cursor-hc.css +34 -0
- package/themes/cursor-midnight.css +34 -0
- package/themes/cursor.css +62 -0
- package/themes/dark-plus.css +66 -0
- package/themes/doom-64.css +109 -0
- package/themes/dracula.css +33 -0
- package/themes/everforest-hard.css +62 -0
- package/themes/everforest-soft.css +62 -0
- package/themes/everforest.css +62 -0
- package/themes/github.css +66 -0
- package/themes/gruvbox.css +62 -0
- package/themes/houston.css +66 -0
- package/themes/kanagawa-dragon.css +34 -0
- package/themes/kanagawa-lotus.css +34 -0
- package/themes/kanagawa-wave.css +34 -0
- package/themes/laserwave.css +66 -0
- package/themes/material.css +62 -0
- package/themes/min.css +66 -0
- package/themes/monokai-pro.css +34 -0
- package/themes/neutral.css +59 -0
- package/themes/night-owl.css +66 -0
- package/themes/nord.css +66 -0
- package/themes/one-dark-pro.css +66 -0
- package/themes/one-light.css +66 -0
- package/themes/paulmillr.css +34 -0
- package/themes/plannotator.css +60 -0
- package/themes/plastic.css +66 -0
- package/themes/poimandres.css +66 -0
- package/themes/quantum-rose.css +109 -0
- package/themes/red.css +66 -0
- package/themes/rose-pine.css +62 -0
- package/themes/simple.css +124 -0
- package/themes/slack.css +66 -0
- package/themes/snazzy-light.css +66 -0
- package/themes/soft-pop.css +60 -0
- package/themes/solar-dusk.css +109 -0
- package/themes/solarized.css +66 -0
- package/themes/synthwave-84.css +34 -0
- package/themes/terminal.css +62 -0
- package/themes/tinacious.css +57 -0
- package/themes/tokyo-night.css +62 -0
- package/themes/vesper.css +62 -0
- package/themes/vitesse-black.css +66 -0
- package/themes/vitesse.css +62 -0
- package/types.ts +264 -0
- package/utils/agentSwitch.ts +71 -0
- package/utils/aiChatFormat.ts +33 -0
- package/utils/aiPrompt.ts +29 -0
- package/utils/aiProvider.ts +217 -0
- package/utils/anchors.ts +11 -0
- package/utils/annotateAgentTerminal.ts +29 -0
- package/utils/annotationHelpers.ts +101 -0
- package/utils/bear.ts +59 -0
- package/utils/blockTargeting.ts +240 -0
- package/utils/callback.ts +99 -0
- package/utils/commentContent.ts +8 -0
- package/utils/defaultNotesApp.ts +20 -0
- package/utils/diffFonts.ts +33 -0
- package/utils/editorMode.ts +31 -0
- package/utils/fileBrowser.ts +40 -0
- package/utils/generateId.ts +7 -0
- package/utils/generateIdentity.ts +29 -0
- package/utils/identity.ts +112 -0
- package/utils/inlineTransforms.ts +41 -0
- package/utils/inputMethod.ts +17 -0
- package/utils/lookAndFeelAnnouncement.ts +18 -0
- package/utils/obsidian.ts +201 -0
- package/utils/octarine.ts +53 -0
- package/utils/parser.ts +991 -0
- package/utils/permissionMode.ts +77 -0
- package/utils/planAIAnnouncement.ts +17 -0
- package/utils/planAgentInstructions.ts +137 -0
- package/utils/planDiffEngine.ts +590 -0
- package/utils/planSave.ts +49 -0
- package/utils/platform.ts +11 -0
- package/utils/quickLabels.ts +77 -0
- package/utils/reviewAgentInstructions.ts +182 -0
- package/utils/sanitizeHtml.ts +45 -0
- package/utils/sharing.ts +350 -0
- package/utils/slugify.ts +37 -0
- package/utils/storage.ts +141 -0
- package/utils/themeRegistry.ts +567 -0
- package/utils/uiPreferences.ts +34 -0
- package/utils/upload.ts +56 -0
- package/utils/wideMode.ts +48 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface SparklesIconProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
animated?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Three-star sparkle icon used for Ask AI entry points.
|
|
10
|
+
* Uses currentColor for fill so it inherits from parent text color.
|
|
11
|
+
*/
|
|
12
|
+
export const SparklesIcon: React.FC<SparklesIconProps> = ({ className = 'w-3 h-3', animated = false }) => (
|
|
13
|
+
<svg className={`${className} flex-shrink-0`} viewBox="0 0 128 128" fill="none">
|
|
14
|
+
{animated && (
|
|
15
|
+
<style>{`
|
|
16
|
+
@keyframes sparkle-idle {
|
|
17
|
+
0%, 100% { opacity: 0.5; }
|
|
18
|
+
50% { opacity: 1; }
|
|
19
|
+
}
|
|
20
|
+
`}</style>
|
|
21
|
+
)}
|
|
22
|
+
<g style={animated ? { animation: 'sparkle-idle 2s infinite ease-in-out', animationDelay: '0.35s' } : undefined}>
|
|
23
|
+
<path
|
|
24
|
+
d="M121.59 60.83l-13.93-4.49c-8.91-2.94-14.13-10.15-16.58-19.21L84.95 7.27c-.16-.59-.55-1.38-1.75-1.38c-1.01 0-1.59.79-1.75 1.38l-6.13 29.87c-2.46 9.06-7.67 16.27-16.58 19.21l-13.93 4.49c-1.97.64-2 3.42-.04 4.09l14.03 4.83c8.88 2.95 14.06 10.15 16.52 19.17l6.14 29.53c.16.59.49 1.65 1.75 1.65c1.33 0 1.59-1.06 1.75-1.65l6.14-29.53c2.46-9.03 7.64-16.23 16.52-19.17l14.03-4.83c1.94-.68 1.91-3.46-.06-4.1z"
|
|
25
|
+
fill="currentColor"
|
|
26
|
+
/>
|
|
27
|
+
<path
|
|
28
|
+
d="M122.91 62.08c-.22-.55-.65-1.03-1.32-1.25l-13.93-4.49c-8.91-2.94-14.13-10.15-16.58-19.21L84.95 7.27c-.09-.34-.41-.96-.78-1.14l1.98 29.97c1.47 13.68 2.73 20.12 13.65 22c9.38 1.62 20.23 3.48 23.11 3.98z"
|
|
29
|
+
fill="currentColor" opacity={0.7}
|
|
30
|
+
/>
|
|
31
|
+
</g>
|
|
32
|
+
<g style={animated ? { animation: 'sparkle-idle 2s infinite ease-in-out', animationDelay: '0.15s' } : undefined}>
|
|
33
|
+
<path
|
|
34
|
+
d="M41.81 86.81c-8.33-2.75-9.09-5.85-10.49-11.08l-3.49-12.24c-.21-.79-2.27-.79-2.49 0L22.97 74.8c-1.41 5.21-4.41 9.35-9.53 11.04l-8.16 3.54c-1.13.37-1.15 1.97-.02 2.35l8.22 2.91c5.1 1.69 8.08 5.83 9.5 11.02l2.37 10.82c.22.79 2.27.79 2.48 0l2.78-10.77c1.41-5.22 3.57-9.37 10.5-11.07l7.72-2.91c1.13-.39 1.12-1.99-.02-2.36l-7-2.56z"
|
|
35
|
+
fill="currentColor"
|
|
36
|
+
/>
|
|
37
|
+
</g>
|
|
38
|
+
<g style={animated ? { animation: 'sparkle-idle 2s infinite ease-in-out', animationDelay: '0.5s' } : undefined}>
|
|
39
|
+
<path
|
|
40
|
+
d="M59.74 28.14c.56-.19.54-.99-.03-1.15l-7.72-2.08a4.77 4.77 0 0 1-3.34-3.3L45.61 9.06c-.15-.61-1.02-.61-1.17.01l-2.86 12.5a4.734 4.734 0 0 1-3.4 3.37l-7.67 1.99c-.57.15-.61.95-.05 1.15l8.09 2.8c1.45.5 2.57 1.68 3.01 3.15l2.89 11.59c.15.6 1.01.61 1.16 0l2.99-11.63a4.773 4.773 0 0 1 3.04-3.13l8.1-2.72z"
|
|
41
|
+
fill="currentColor" opacity={0.5}
|
|
42
|
+
/>
|
|
43
|
+
</g>
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StickyHeaderLane — compact "ghost" header that pins as the user scrolls
|
|
3
|
+
* past the AnnotationToolstrip.
|
|
4
|
+
*
|
|
5
|
+
* At rest (top of doc): invisible, non-interactive. The original toolstrip
|
|
6
|
+
* and badge cluster on the card remain the visible source of truth.
|
|
7
|
+
*
|
|
8
|
+
* Layout is driven by two ResizeObserver measurements — the sticky
|
|
9
|
+
* wrapper's actual width AND the Viewer action button cluster's actual
|
|
10
|
+
* width — so the bar fits exactly into the space between its left edge
|
|
11
|
+
* and the buttons, with no fixed pixel reserves.
|
|
12
|
+
*
|
|
13
|
+
* availableForBar = wrapperWidth - LEFT_OFFSET - actionsWidth - GAP
|
|
14
|
+
*
|
|
15
|
+
* Three states based on availableForBar:
|
|
16
|
+
* wide (>= WIDE_BAR_WIDTH): shared lane, toolstrip with active labels.
|
|
17
|
+
* tight (>= MIN_BAR_WIDTH): shared lane, toolstrip icon-only — gives
|
|
18
|
+
* the bar another ~160px of room before
|
|
19
|
+
* forcing a layout change.
|
|
20
|
+
* narrow (< MIN_BAR_WIDTH): stacked below the action buttons on its
|
|
21
|
+
* own full-width row, icon-only toolstrip.
|
|
22
|
+
*
|
|
23
|
+
* Composes <AnnotationToolstrip compact /> + <DocBadges layout="row" />.
|
|
24
|
+
* No state is duplicated — all props are passed through from App.tsx.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
28
|
+
import { AnnotationToolstrip } from './AnnotationToolstrip';
|
|
29
|
+
import { DocBadges } from './DocBadges';
|
|
30
|
+
import { useScrollViewport } from '../hooks/useScrollViewport';
|
|
31
|
+
import type { EditorMode, InputMethod } from '../types';
|
|
32
|
+
import type { PlanDiffStats } from '../utils/planDiffEngine';
|
|
33
|
+
|
|
34
|
+
// Snap a measured pixel width to a 16px grid. ResizeObserver fires every
|
|
35
|
+
// frame during a drag; without quantization the sticky bar would
|
|
36
|
+
// re-render on every pixel. Hoisted to module scope so the effects
|
|
37
|
+
// (which use [] deps) can't accidentally close over a stale instance.
|
|
38
|
+
// Floor (not round) so wrapper undershoots and actions overshoots — both
|
|
39
|
+
// errors push toward a more cautious layout, avoiding a one-bucket overlap
|
|
40
|
+
// flash right at the 300/460 thresholds during a slow drag.
|
|
41
|
+
const snap = (n: number) => Math.floor(n / 16) * 16;
|
|
42
|
+
|
|
43
|
+
// Layout geometry — static tuning constants, hoisted alongside `snap`.
|
|
44
|
+
// LEFT_OFFSET: matches the bar's `md:left-5` (20px).
|
|
45
|
+
// GAP: minimum breathing room between the bar's right edge and the
|
|
46
|
+
// action button cluster's left edge when they share a lane.
|
|
47
|
+
// Two-stage shared-lane shrinkage, mirroring the right side:
|
|
48
|
+
// WIDE_BAR_WIDTH: full toolstrip (active labels Pinpoint/Markup ~300px)
|
|
49
|
+
// + badges (~140px) on a single line.
|
|
50
|
+
// MIN_BAR_WIDTH: icon-only toolstrip (~140px) + badges (~140px).
|
|
51
|
+
// Below MIN, even the icon-only bar can't fit beside the (likely also
|
|
52
|
+
// icon-only) action cluster — stack as the final fallback.
|
|
53
|
+
const LEFT_OFFSET = 20;
|
|
54
|
+
const GAP = 16;
|
|
55
|
+
const WIDE_BAR_WIDTH = 460;
|
|
56
|
+
const MIN_BAR_WIDTH = 300;
|
|
57
|
+
|
|
58
|
+
interface StickyHeaderLaneProps {
|
|
59
|
+
// Toolstrip state
|
|
60
|
+
inputMethod: InputMethod;
|
|
61
|
+
onInputMethodChange: (method: InputMethod) => void;
|
|
62
|
+
mode: EditorMode;
|
|
63
|
+
onModeChange: (mode: EditorMode) => void;
|
|
64
|
+
taterMode?: boolean;
|
|
65
|
+
|
|
66
|
+
// Badge state
|
|
67
|
+
repoInfo?: { display: string; branch?: string } | null;
|
|
68
|
+
planDiffStats?: PlanDiffStats | null;
|
|
69
|
+
isPlanDiffActive?: boolean;
|
|
70
|
+
hasPreviousVersion?: boolean;
|
|
71
|
+
onPlanDiffToggle?: () => void;
|
|
72
|
+
archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null;
|
|
73
|
+
|
|
74
|
+
// Layout
|
|
75
|
+
maxWidth?: number | null;
|
|
76
|
+
|
|
77
|
+
// Re-query token for the [data-sticky-actions] ResizeObserver. When the
|
|
78
|
+
// Viewer remounts (e.g., toggling a linked doc), its `data-sticky-actions`
|
|
79
|
+
// node is replaced — but StickyHeaderLane itself does NOT remount, so
|
|
80
|
+
// its observer would otherwise stay attached to the now-detached old
|
|
81
|
+
// node and freeze `actionsWidth`. Pass a string that changes whenever
|
|
82
|
+
// Viewer remounts and the effect re-runs against the fresh DOM.
|
|
83
|
+
remountToken?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
|
|
87
|
+
inputMethod,
|
|
88
|
+
onInputMethodChange,
|
|
89
|
+
mode,
|
|
90
|
+
onModeChange,
|
|
91
|
+
taterMode,
|
|
92
|
+
repoInfo,
|
|
93
|
+
planDiffStats,
|
|
94
|
+
isPlanDiffActive,
|
|
95
|
+
hasPreviousVersion,
|
|
96
|
+
onPlanDiffToggle,
|
|
97
|
+
archiveInfo,
|
|
98
|
+
maxWidth,
|
|
99
|
+
remountToken,
|
|
100
|
+
}) => {
|
|
101
|
+
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
102
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
103
|
+
const [isStuck, setIsStuck] = useState(false);
|
|
104
|
+
const [wrapperWidth, setWrapperWidth] = useState(0);
|
|
105
|
+
const [actionsWidth, setActionsWidth] = useState(0);
|
|
106
|
+
const scrollViewport = useScrollViewport();
|
|
107
|
+
|
|
108
|
+
// Space available for the bar in the shared lane = wrapper width, minus
|
|
109
|
+
// the bar's left offset, minus the action buttons' measured width, minus
|
|
110
|
+
// the breathing gap.
|
|
111
|
+
const availableForBar = wrapperWidth - LEFT_OFFSET - actionsWidth - GAP;
|
|
112
|
+
|
|
113
|
+
// Narrow = not enough room in the shared lane for even the icon-only
|
|
114
|
+
// bar. Falls back to a stacked row below the action buttons.
|
|
115
|
+
// actionsWidth=0 before measurement is treated as "don't know yet"
|
|
116
|
+
// (not narrow), so we don't flash the wrong layout on first paint.
|
|
117
|
+
const measured = wrapperWidth > 0 && actionsWidth > 0;
|
|
118
|
+
const isNarrow = measured && availableForBar < MIN_BAR_WIDTH;
|
|
119
|
+
// Tight = shared lane still fits, but only if the toolstrip drops its
|
|
120
|
+
// active labels and goes icon-only. Lets us stay horizontally aligned
|
|
121
|
+
// for an extra ~160px of width before stacking.
|
|
122
|
+
const isToolstripIconOnly =
|
|
123
|
+
measured && !isNarrow && availableForBar < WIDE_BAR_WIDTH;
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!wrapperRef.current) return;
|
|
127
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
128
|
+
const next = snap(entry.contentRect.width);
|
|
129
|
+
setWrapperWidth((prev) => (prev === next ? prev : next));
|
|
130
|
+
});
|
|
131
|
+
ro.observe(wrapperRef.current);
|
|
132
|
+
return () => ro.disconnect();
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
// Measure the Viewer's action button cluster so the right reserve
|
|
136
|
+
// reflects its REAL width (which varies with viewport — short vs full
|
|
137
|
+
// labels — and with button count). No more guessing at ~310/360/400px.
|
|
138
|
+
// The action button div is tagged with `data-sticky-actions` in
|
|
139
|
+
// Viewer.tsx. It's a sibling in the DOM by the time effects fire.
|
|
140
|
+
// Re-runs when `remountToken` changes so we re-query after Viewer
|
|
141
|
+
// unmounts/remounts (e.g., linked-doc toggle) instead of leaving the
|
|
142
|
+
// observer attached to a detached node.
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
// Reset to the unmeasured state so the bar falls back to the safe
|
|
145
|
+
// "no maxWidth cap" path for the one frame between Viewer remounting
|
|
146
|
+
// and the new observer firing its first callback.
|
|
147
|
+
setActionsWidth(0);
|
|
148
|
+
const el = document.querySelector<HTMLElement>('[data-sticky-actions]');
|
|
149
|
+
if (!el) return;
|
|
150
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
151
|
+
const next = snap(entry.contentRect.width);
|
|
152
|
+
setActionsWidth((prev) => (prev === next ? prev : next));
|
|
153
|
+
});
|
|
154
|
+
ro.observe(el);
|
|
155
|
+
return () => ro.disconnect();
|
|
156
|
+
}, [remountToken]);
|
|
157
|
+
|
|
158
|
+
// IntersectionObserver-on-sentinel pattern (mirrors Viewer.tsx:257-267).
|
|
159
|
+
// Sentinel sits inline at the top of the column. The 80px positive top
|
|
160
|
+
// rootMargin grows the effective viewport upward so the sentinel is
|
|
161
|
+
// considered "visible" for an extra ~80px of scroll — delaying the bar's
|
|
162
|
+
// appearance until the real toolstrip has actually scrolled past. Without
|
|
163
|
+
// this, the sentinel fires the moment scrolling begins and the ghost bar
|
|
164
|
+
// doubles up with the still-visible toolstrip. Root is the OverlayScrollArea
|
|
165
|
+
// viewport from context, NOT <main> (which doesn't actually scroll).
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!sentinelRef.current || !scrollViewport) return;
|
|
168
|
+
const observer = new IntersectionObserver(
|
|
169
|
+
([entry]) => setIsStuck(!entry.isIntersecting),
|
|
170
|
+
{ root: scrollViewport, rootMargin: '80px 0px 0px 0px', threshold: 0 }
|
|
171
|
+
);
|
|
172
|
+
observer.observe(sentinelRef.current);
|
|
173
|
+
return () => observer.disconnect();
|
|
174
|
+
}, [scrollViewport]);
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<>
|
|
178
|
+
{/* Sentinel — zero-size, rendered in normal flow at the top of the
|
|
179
|
+
column. When it scrolls out of the OverlayScrollArea viewport,
|
|
180
|
+
the sticky bar fades in. */}
|
|
181
|
+
<div ref={sentinelRef} aria-hidden="true" className="h-0 w-0" />
|
|
182
|
+
|
|
183
|
+
{/* Sticky wrapper — zero-height so it never pushes content down. The
|
|
184
|
+
visible bar is positioned absolutely relative to this wrapper.
|
|
185
|
+
The Viewer's outer wrapper uses z-50, so the sticky lane must
|
|
186
|
+
sit above that to paint over the card.
|
|
187
|
+
|
|
188
|
+
Narrow: the bar pins at top-[52px] / md:top-[60px] on its OWN
|
|
189
|
+
full-width row BELOW the card's sticky action buttons. Stacked
|
|
190
|
+
horizontal lanes, no horizontal collision possible.
|
|
191
|
+
|
|
192
|
+
Wide / tight: the bar shares the top-3 lane with the action
|
|
193
|
+
buttons (single horizontal header). */}
|
|
194
|
+
<div
|
|
195
|
+
ref={wrapperRef}
|
|
196
|
+
data-sticky-header-lane="true"
|
|
197
|
+
className={`sticky z-[60] w-full self-center pointer-events-none ${
|
|
198
|
+
isNarrow ? 'top-[52px] md:top-[60px]' : 'top-3'
|
|
199
|
+
}`}
|
|
200
|
+
style={maxWidth == null ? { height: 0 } : { maxWidth, height: 0 }}
|
|
201
|
+
>
|
|
202
|
+
{/* Responsive bar.
|
|
203
|
+
|
|
204
|
+
`inline-flex flex-wrap` + a measured `max-width` cap (set inline
|
|
205
|
+
below) lets the bar wrap badges to a second row if the toolstrip
|
|
206
|
+
+ badges can't fit on one line. The max-width is computed from
|
|
207
|
+
the real measured action button width — no fixed reserves.
|
|
208
|
+
|
|
209
|
+
`flex-shrink-0` on the toolstrip wrapper is a defensive measure:
|
|
210
|
+
if a long branch name pushes the badges, this stops the toolstrip
|
|
211
|
+
from being squeezed below its natural width. `overflow-hidden`
|
|
212
|
+
is the final safety net so any overflow clips inside the chrome
|
|
213
|
+
rather than leaking out.
|
|
214
|
+
|
|
215
|
+
`inert` removes the bar from the tab order when not stuck. */}
|
|
216
|
+
<div
|
|
217
|
+
inert={!isStuck || undefined}
|
|
218
|
+
className={`absolute left-3 md:left-5 top-0 inline-flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0 overflow-hidden rounded-lg py-1 md:py-1.5 bg-card/95 backdrop-blur-sm shadow-sm border border-border/30 motion-reduce:transform-none ${
|
|
219
|
+
isStuck
|
|
220
|
+
? 'opacity-100 translate-y-0 pointer-events-auto'
|
|
221
|
+
: 'opacity-0 -translate-y-1 pointer-events-none'
|
|
222
|
+
}`}
|
|
223
|
+
style={{
|
|
224
|
+
paddingLeft: 12,
|
|
225
|
+
paddingRight: 12,
|
|
226
|
+
maxWidth: isNarrow
|
|
227
|
+
? 'calc(100% - 24px)'
|
|
228
|
+
: availableForBar > 0
|
|
229
|
+
? availableForBar
|
|
230
|
+
: undefined,
|
|
231
|
+
transition:
|
|
232
|
+
'opacity 180ms cubic-bezier(0.2, 0, 0, 1), transform 180ms cubic-bezier(0.2, 0, 0, 1)',
|
|
233
|
+
willChange: 'opacity, transform',
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<div className="flex-shrink-0">
|
|
237
|
+
<AnnotationToolstrip
|
|
238
|
+
inputMethod={inputMethod}
|
|
239
|
+
onInputMethodChange={onInputMethodChange}
|
|
240
|
+
mode={mode}
|
|
241
|
+
onModeChange={onModeChange}
|
|
242
|
+
taterMode={taterMode}
|
|
243
|
+
compact
|
|
244
|
+
iconOnly={isNarrow || isToolstripIconOnly}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
<DocBadges
|
|
248
|
+
layout="row"
|
|
249
|
+
repoInfo={repoInfo}
|
|
250
|
+
planDiffStats={planDiffStats}
|
|
251
|
+
isPlanDiffActive={isPlanDiffActive}
|
|
252
|
+
hasPreviousVersion={hasPreviousVersion}
|
|
253
|
+
onPlanDiffToggle={onPlanDiffToggle}
|
|
254
|
+
archiveInfo={archiveInfo}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</>
|
|
259
|
+
);
|
|
260
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React, { useMemo, useCallback } from 'react';
|
|
2
|
+
import type { Block, Annotation } from '../types';
|
|
3
|
+
import {
|
|
4
|
+
buildTocHierarchy,
|
|
5
|
+
getAnnotationCountBySection,
|
|
6
|
+
type TocItem,
|
|
7
|
+
} from '../utils/annotationHelpers';
|
|
8
|
+
import { useScrollViewport } from '../hooks/useScrollViewport';
|
|
9
|
+
|
|
10
|
+
interface TableOfContentsProps {
|
|
11
|
+
blocks: Block[];
|
|
12
|
+
annotations: Annotation[];
|
|
13
|
+
activeId: string | null;
|
|
14
|
+
onNavigate: (blockId: string) => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
style?: React.CSSProperties;
|
|
17
|
+
linkedDocFilepath?: string | null;
|
|
18
|
+
onLinkedDocBack?: () => void;
|
|
19
|
+
backLabel?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The prototype's TOC is a FLAT list — heading depth is conveyed by indentation
|
|
23
|
+
// + tonal de-emphasis, not an expand/collapse chevron tree. Flatten the built
|
|
24
|
+
// hierarchy (depth-first = document order) into a single ordered list.
|
|
25
|
+
function flattenToc(items: TocItem[]): TocItem[] {
|
|
26
|
+
const out: TocItem[] = [];
|
|
27
|
+
const walk = (list: TocItem[]) => {
|
|
28
|
+
for (const it of list) {
|
|
29
|
+
out.push(it);
|
|
30
|
+
if (it.children.length) walk(it.children);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
walk(items);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Indentation + tonal de-emphasis by heading level (prototype style):
|
|
38
|
+
// H1 flush + near-full strength, H2/H3 indented and dimmed to muted. Active row
|
|
39
|
+
// is a soft neutral surface tint (not a loud primary fill).
|
|
40
|
+
function itemClasses(level: number, isActive: boolean): string {
|
|
41
|
+
const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : 'ml-6';
|
|
42
|
+
const tone = isActive
|
|
43
|
+
? 'bg-surface-1 text-foreground'
|
|
44
|
+
: level <= 1
|
|
45
|
+
? 'text-foreground/80 hover:bg-surface-1/70'
|
|
46
|
+
: 'text-muted-foreground hover:bg-surface-1/70';
|
|
47
|
+
return `${indent} ${tone}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function TableOfContents({
|
|
51
|
+
blocks,
|
|
52
|
+
annotations,
|
|
53
|
+
activeId,
|
|
54
|
+
onNavigate,
|
|
55
|
+
className = '',
|
|
56
|
+
style,
|
|
57
|
+
linkedDocFilepath,
|
|
58
|
+
onLinkedDocBack,
|
|
59
|
+
backLabel,
|
|
60
|
+
}: TableOfContentsProps) {
|
|
61
|
+
// Annotation count per section (kept — production feature).
|
|
62
|
+
const annotationCounts = useMemo(
|
|
63
|
+
() => getAnnotationCountBySection(blocks, annotations),
|
|
64
|
+
[blocks, annotations]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Build the hierarchy (filters to heading levels ≤ 3 and attaches counts),
|
|
68
|
+
// then flatten to a plain list.
|
|
69
|
+
const tocItems = useMemo(
|
|
70
|
+
() => flattenToc(buildTocHierarchy(blocks, annotationCounts)),
|
|
71
|
+
[blocks, annotationCounts]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// The real scroll element is the OverlayScrollArea viewport, not <main>.
|
|
75
|
+
const scrollViewport = useScrollViewport();
|
|
76
|
+
|
|
77
|
+
// Smooth scroll-to-heading, accounting for the sticky header.
|
|
78
|
+
const handleNavigate = useCallback(
|
|
79
|
+
(blockId: string) => {
|
|
80
|
+
onNavigate(blockId);
|
|
81
|
+
const target = (scrollViewport ?? document).querySelector(`[data-block-id="${blockId}"]`);
|
|
82
|
+
if (target && scrollViewport) {
|
|
83
|
+
const scrollContainer = scrollViewport;
|
|
84
|
+
const headerOffset = 80; // sticky header (h-12) + breathing room
|
|
85
|
+
const containerRect = scrollContainer.getBoundingClientRect();
|
|
86
|
+
const targetRect = target.getBoundingClientRect();
|
|
87
|
+
const offsetPosition =
|
|
88
|
+
scrollContainer.scrollTop + (targetRect.top - containerRect.top) - headerOffset;
|
|
89
|
+
scrollContainer.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
[onNavigate, scrollViewport]
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (tocItems.length === 0) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<nav
|
|
101
|
+
// Use ?? not || — an explicit empty string from a caller means "I'm
|
|
102
|
+
// managing my own container styling" (e.g. SidebarContainer wrapping
|
|
103
|
+
// us in an OverlayScrollArea), which should NOT trigger the default.
|
|
104
|
+
className={className ?? 'bg-card/50 backdrop-blur-sm border-r border-border overflow-y-auto'}
|
|
105
|
+
aria-label="Table of contents"
|
|
106
|
+
style={style}
|
|
107
|
+
>
|
|
108
|
+
<div className="p-1.5">
|
|
109
|
+
{linkedDocFilepath && (
|
|
110
|
+
<div className="mb-2 px-0.5 pb-1.5 border-b border-border/50">
|
|
111
|
+
<div className="flex items-center justify-between">
|
|
112
|
+
<span className="text-[10px] font-medium text-primary/80">Viewing</span>
|
|
113
|
+
{onLinkedDocBack && (
|
|
114
|
+
<button
|
|
115
|
+
onClick={onLinkedDocBack}
|
|
116
|
+
className="flex items-center gap-0.5 text-[10px] font-medium text-primary hover:text-primary/80 transition-colors"
|
|
117
|
+
>
|
|
118
|
+
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
119
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
|
120
|
+
</svg>
|
|
121
|
+
Back to {backLabel || 'plan'}
|
|
122
|
+
</button>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
<p className="text-[11px] text-foreground/70 truncate mt-0.5" title={linkedDocFilepath}>
|
|
126
|
+
{linkedDocFilepath.split('/').pop()}
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
<div className="flex flex-col gap-0.5">
|
|
131
|
+
{tocItems.map((item) => {
|
|
132
|
+
const isActive = item.id === activeId;
|
|
133
|
+
return (
|
|
134
|
+
<button
|
|
135
|
+
key={item.id}
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={() => handleNavigate(item.id)}
|
|
138
|
+
aria-current={isActive ? 'location' : undefined}
|
|
139
|
+
className={`flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-[11px] font-medium leading-snug transition-colors ${itemClasses(
|
|
140
|
+
item.level,
|
|
141
|
+
isActive
|
|
142
|
+
)}`}
|
|
143
|
+
>
|
|
144
|
+
<span className="line-clamp-2">{item.content}</span>
|
|
145
|
+
{item.annotationCount > 0 && (
|
|
146
|
+
<span className="ml-1 flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[9px] text-primary">
|
|
147
|
+
{item.annotationCount}
|
|
148
|
+
</span>
|
|
149
|
+
)}
|
|
150
|
+
</button>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</nav>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/// <reference path="../globals.d.ts" />
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import spriteSheet from '../sprite_package_pulluphang/sprite.png';
|
|
4
|
+
|
|
5
|
+
// Sprite specs: 96x96 native, 24 frames, 3 seconds
|
|
6
|
+
const NATIVE_SIZE = 96;
|
|
7
|
+
const DISPLAY_SIZE = 56;
|
|
8
|
+
const FRAMES = 24;
|
|
9
|
+
const SCALE = DISPLAY_SIZE / NATIVE_SIZE;
|
|
10
|
+
const TOTAL_WIDTH = NATIVE_SIZE * FRAMES * SCALE;
|
|
11
|
+
|
|
12
|
+
export const TaterSpritePullup: React.FC = () => {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className="absolute pointer-events-none hidden md:block -z-10"
|
|
16
|
+
style={{
|
|
17
|
+
bottom: -49, // Hangs off bottom of dialog
|
|
18
|
+
left: 12,
|
|
19
|
+
width: DISPLAY_SIZE,
|
|
20
|
+
height: DISPLAY_SIZE,
|
|
21
|
+
backgroundImage: `url(${spriteSheet})`,
|
|
22
|
+
backgroundSize: `${TOTAL_WIDTH}px ${DISPLAY_SIZE}px`,
|
|
23
|
+
animation: 'tater-pullup 3.5s steps(24) infinite',
|
|
24
|
+
imageRendering: 'pixelated',
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<style>{`
|
|
28
|
+
@keyframes tater-pullup {
|
|
29
|
+
to { background-position: -${TOTAL_WIDTH}px 0; }
|
|
30
|
+
}
|
|
31
|
+
`}</style>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/// <reference path="../globals.d.ts" />
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import spriteSheet from '../sprite_package_additional/sprite.png';
|
|
4
|
+
|
|
5
|
+
// Sprite: 176x96 native, 24 frames, 5 second animation cycle
|
|
6
|
+
// Running across screen should sync with animation speed
|
|
7
|
+
const NATIVE_WIDTH = 176;
|
|
8
|
+
const NATIVE_HEIGHT = 96;
|
|
9
|
+
const DISPLAY_HEIGHT = 64;
|
|
10
|
+
const SCALE = DISPLAY_HEIGHT / NATIVE_HEIGHT;
|
|
11
|
+
const DISPLAY_WIDTH = NATIVE_WIDTH * SCALE; // ~117px
|
|
12
|
+
const FRAMES = 24;
|
|
13
|
+
const FRAME_DURATION = 5; // seconds for full run cycle
|
|
14
|
+
const TOTAL_SPRITE_WIDTH = NATIVE_WIDTH * FRAMES * SCALE;
|
|
15
|
+
|
|
16
|
+
// Screen traverse: character runs across in ~12 seconds
|
|
17
|
+
// This gives ~2.4 run cycles per screen width, feels natural
|
|
18
|
+
const SCREEN_TRAVERSE_TIME = 18;
|
|
19
|
+
|
|
20
|
+
export const TaterSpriteRunning: React.FC = () => {
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
className="fixed pointer-events-none hidden md:block"
|
|
24
|
+
style={{
|
|
25
|
+
bottom: 0,
|
|
26
|
+
right: -DISPLAY_WIDTH, // Start off-screen right
|
|
27
|
+
width: DISPLAY_WIDTH,
|
|
28
|
+
height: DISPLAY_HEIGHT,
|
|
29
|
+
zIndex: 5, // Above sidebars (z-auto) but below plan document (z-10)
|
|
30
|
+
backgroundImage: `url(${spriteSheet})`,
|
|
31
|
+
backgroundSize: `${TOTAL_SPRITE_WIDTH}px ${DISPLAY_HEIGHT}px`,
|
|
32
|
+
backgroundPosition: 'left center',
|
|
33
|
+
imageRendering: 'pixelated',
|
|
34
|
+
animation: `tater-run-sprite ${FRAME_DURATION}s steps(${FRAMES}) infinite, tater-run-across ${SCREEN_TRAVERSE_TIME}s linear infinite`,
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<style>{`
|
|
38
|
+
@keyframes tater-run-sprite {
|
|
39
|
+
to {
|
|
40
|
+
background-position: -${TOTAL_SPRITE_WIDTH}px 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
@keyframes tater-run-across {
|
|
44
|
+
from {
|
|
45
|
+
right: -${DISPLAY_WIDTH}px;
|
|
46
|
+
}
|
|
47
|
+
to {
|
|
48
|
+
right: 100vw;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`}</style>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/// <reference path="../globals.d.ts" />
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import spriteSheet from '../sprite_package_new/sprite.png';
|
|
4
|
+
|
|
5
|
+
// Sprite: 96x96 native, 12 frames, scaled to 64x64 for display
|
|
6
|
+
const NATIVE_SIZE = 96;
|
|
7
|
+
const DISPLAY_SIZE = 64;
|
|
8
|
+
const FRAMES = 12;
|
|
9
|
+
const SCALE = DISPLAY_SIZE / NATIVE_SIZE;
|
|
10
|
+
const TOTAL_WIDTH = NATIVE_SIZE * FRAMES * SCALE; // 768px at 64px scale
|
|
11
|
+
|
|
12
|
+
export const TaterSpriteSitting: React.FC = () => {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className="hidden md:block absolute pointer-events-none z-10"
|
|
16
|
+
style={{
|
|
17
|
+
top: -40,
|
|
18
|
+
right: -4,
|
|
19
|
+
width: DISPLAY_SIZE,
|
|
20
|
+
height: DISPLAY_SIZE,
|
|
21
|
+
backgroundImage: `url(${spriteSheet})`,
|
|
22
|
+
backgroundSize: `${TOTAL_WIDTH}px ${DISPLAY_SIZE}px`,
|
|
23
|
+
backgroundPosition: 'left center',
|
|
24
|
+
animation: 'tater-sit 3s steps(12) infinite',
|
|
25
|
+
imageRendering: 'pixelated',
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<style>{`
|
|
29
|
+
@keyframes tater-sit {
|
|
30
|
+
to {
|
|
31
|
+
background-position: -${TOTAL_WIDTH}px 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
`}</style>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React, { useMemo, type JSX } from 'react';
|
|
2
|
+
import { motion } from 'motion/react';
|
|
3
|
+
|
|
4
|
+
export interface TextShimmerProps {
|
|
5
|
+
children: string;
|
|
6
|
+
as?: React.ElementType;
|
|
7
|
+
className?: string;
|
|
8
|
+
duration?: number;
|
|
9
|
+
spread?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function TextShimmerComponent({
|
|
13
|
+
children,
|
|
14
|
+
as: Component = 'span',
|
|
15
|
+
className,
|
|
16
|
+
duration = 2,
|
|
17
|
+
spread = 2,
|
|
18
|
+
}: TextShimmerProps) {
|
|
19
|
+
const MotionComponent = motion.create(
|
|
20
|
+
Component as keyof JSX.IntrinsicElements
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const dynamicSpread = useMemo(() => {
|
|
24
|
+
return children.length * spread;
|
|
25
|
+
}, [children, spread]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<MotionComponent
|
|
29
|
+
className={[
|
|
30
|
+
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text',
|
|
31
|
+
'text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]',
|
|
32
|
+
'[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]',
|
|
33
|
+
'dark:[--base-color:#71717a] dark:[--base-gradient-color:#ffffff] dark:[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]',
|
|
34
|
+
className,
|
|
35
|
+
]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join(' ')}
|
|
38
|
+
initial={{ backgroundPosition: '100% center' }}
|
|
39
|
+
animate={{ backgroundPosition: '0% center' }}
|
|
40
|
+
transition={{
|
|
41
|
+
repeat: Infinity,
|
|
42
|
+
duration,
|
|
43
|
+
ease: 'linear',
|
|
44
|
+
}}
|
|
45
|
+
style={
|
|
46
|
+
{
|
|
47
|
+
'--spread': `${dynamicSpread}px`,
|
|
48
|
+
backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`,
|
|
49
|
+
} as React.CSSProperties
|
|
50
|
+
}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</MotionComponent>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const TextShimmer = React.memo(TextShimmerComponent);
|