tfrere HF Staff commited on
Commit
f7b880e
·
1 Parent(s): 7261d31
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +6 -6
  2. CHANGELOG.md +118 -0
  3. CONTRIBUTING.md +196 -0
  4. Dockerfile +15 -21
  5. LICENSE +33 -0
  6. app/astro.config.mjs +4 -2
  7. app/package.json +0 -0
  8. app/public/scripts/color-palettes.js +82 -44
  9. app/scripts/notion-importer/.notion-to-md/media/27877f1c-9c9d-804d-9c82-f7b3905578ff_media.json +3 -198
  10. app/scripts/notion-importer/README.md +3 -33
  11. app/scripts/notion-importer/env.example +72 -2
  12. app/scripts/notion-importer/index.mjs +8 -80
  13. app/scripts/notion-importer/input/pages.json +3 -9
  14. app/scripts/notion-importer/mdx-converter.mjs +12 -12
  15. app/scripts/notion-importer/notion-converter.mjs +2 -2
  16. app/scripts/sync-template.mjs +361 -0
  17. app/src/components/Glossary.astro +336 -0
  18. app/src/components/Hero.astro +312 -17
  19. app/src/components/HtmlEmbed.astro +8 -3
  20. app/src/components/Image.astro +508 -0
  21. app/src/components/Quote.astro +124 -0
  22. app/src/components/Reference.astro +45 -0
  23. app/src/components/Sidenote.astro +77 -15
  24. app/src/components/Stack.astro +161 -0
  25. app/src/components/demo/ColorPicker.astro +633 -0
  26. app/src/components/demo/Palettes.astro +596 -0
  27. app/src/components/trackio/Trackio.svelte +500 -259
  28. app/src/components/trackio/TrackioWrapper.astro +510 -0
  29. app/src/components/trackio/core/adaptive-sampler.js +9 -9
  30. app/src/content/embeds/banner.html +633 -0
  31. app/src/content/{embeds2 → embeds}/d3-bar.html +0 -0
  32. app/src/content/{embeds2 → embeds}/d3-benchmark.html +0 -0
  33. app/src/content/{embeds2 → embeds}/d3-confusion-matrix.html +0 -0
  34. app/src/content/{embeds2 → embeds}/d3-evals-after-fix.html +0 -0
  35. app/src/content/{embeds2 → embeds}/d3-evals-tpbug.html +0 -0
  36. app/src/content/{embeds2 → embeds}/d3-line-quad.html +0 -0
  37. app/src/content/{embeds2 → embeds}/d3-line.html +0 -0
  38. app/src/content/{embeds2 → embeds}/d3-matrix.html +0 -0
  39. app/src/content/{embeds2 → embeds}/d3-neural-network.html +0 -0
  40. app/src/content/{embeds2 → embeds}/d3-pie-quad.html +0 -0
  41. app/src/content/{embeds2 → embeds}/d3-pie.html +0 -0
  42. app/src/content/{embeds2 → embeds}/d3-scatter.html +0 -0
  43. app/src/content/{embeds2 → embeds}/demo/color-picker.html +0 -0
  44. app/src/content/{embeds2 → embeds}/demo/content-structure.html +0 -0
  45. app/src/content/{embeds2 → embeds}/demo/palettes.html +0 -0
  46. app/src/content/{embeds2 → embeds}/original_embeds/plotly/banner.py +0 -0
  47. app/src/content/{embeds2 → embeds}/original_embeds/plotly/bar.py +0 -0
  48. app/src/content/{embeds2 → embeds}/original_embeds/plotly/heatmap.py +0 -0
  49. app/src/content/{embeds2 → embeds}/original_embeds/plotly/line.py +0 -0
  50. app/src/content/{embeds2 → embeds}/original_embeds/plotly/poetry.lock +0 -0
.gitattributes CHANGED
@@ -1,14 +1,14 @@
1
- # Images et médias
2
  *.png filter=lfs diff=lfs merge=lfs -text
3
  *.jpg filter=lfs diff=lfs merge=lfs -text
4
  *.jpeg filter=lfs diff=lfs merge=lfs -text
5
  *.gif filter=lfs diff=lfs merge=lfs -text
6
-
7
- # Vidéos et audio
8
  *.mp4 filter=lfs diff=lfs merge=lfs -text
9
  *.mov filter=lfs diff=lfs merge=lfs -text
10
  *.avi filter=lfs diff=lfs merge=lfs -text
11
- *.mp3 filter=lfs diff=lfs merge=lfs -text
12
  *.wav filter=lfs diff=lfs merge=lfs -text
13
-
14
- *.pdf filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
1
  *.png filter=lfs diff=lfs merge=lfs -text
2
  *.jpg filter=lfs diff=lfs merge=lfs -text
3
  *.jpeg filter=lfs diff=lfs merge=lfs -text
4
  *.gif filter=lfs diff=lfs merge=lfs -text
5
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
 
6
  *.mp4 filter=lfs diff=lfs merge=lfs -text
7
  *.mov filter=lfs diff=lfs merge=lfs -text
8
  *.avi filter=lfs diff=lfs merge=lfs -text
 
9
  *.wav filter=lfs diff=lfs merge=lfs -text
10
+ *.csv filter=lfs diff=lfs merge=lfs -text
11
+ *.json filter=lfs diff=lfs merge=lfs -text
12
+ # the package and package lock should not be tracked
13
+ package.json -filter -diff -merge text
14
+ package-lock.json -filter -diff -merge text
CHANGELOG.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to the Research Article Template will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - Initial open source release
12
+ - Comprehensive documentation
13
+ - Contributing guidelines
14
+ - License file
15
+
16
+ ## [1.0.0] - 2024-12-19
17
+
18
+ ### Added
19
+ - **Core Features**:
20
+ - Markdown/MDX-based writing system
21
+ - KaTeX mathematical notation support
22
+ - Syntax highlighting for code blocks
23
+ - Academic citations with BibTeX integration
24
+ - Footnotes and sidenotes system
25
+ - Auto-generated table of contents
26
+ - Interactive Mermaid diagrams
27
+ - Plotly.js and D3.js integration
28
+ - HTML embed support
29
+ - Gradio app embedding
30
+ - Dataviz color palettes
31
+ - Image optimization
32
+ - SEO-friendly structure
33
+ - Automatic PDF export
34
+ - Dark/light theme toggle
35
+ - Mobile-responsive design
36
+ - LaTeX import functionality
37
+ - Template synchronization system
38
+
39
+ - **Components**:
40
+ - Figure component with captions
41
+ - MultiFigure for image galleries
42
+ - Note component with variants
43
+ - Quote component
44
+ - Accordion for collapsible content
45
+ - Sidenote component
46
+ - Table of Contents
47
+ - Theme Toggle
48
+ - HTML Embed
49
+ - Raw HTML support
50
+ - SEO component
51
+ - Hero section
52
+ - Footer
53
+ - Full-width and wide layouts
54
+
55
+ - **Build System**:
56
+ - Astro 4.10.0 integration
57
+ - PostCSS with custom media queries
58
+ - Automatic compression
59
+ - Docker support
60
+ - Nginx configuration
61
+ - Git LFS support
62
+
63
+ - **Scripts**:
64
+ - PDF export functionality
65
+ - LaTeX to MDX conversion
66
+ - Template synchronization
67
+ - Font SVG generation
68
+ - TrackIO data generation
69
+
70
+ - **Documentation**:
71
+ - Getting started guide
72
+ - Writing best practices
73
+ - Component reference
74
+ - LaTeX conversion guide
75
+ - Interactive examples
76
+
77
+ ### Technical Details
78
+ - **Framework**: Astro 4.10.0
79
+ - **Styling**: PostCSS with custom properties
80
+ - **Math**: KaTeX 0.16.22
81
+ - **Charts**: Plotly.js 3.1.0, D3.js 7.9.0
82
+ - **Diagrams**: Mermaid 11.10.1
83
+ - **Node.js**: >=20.0.0
84
+ - **License**: CC-BY-4.0
85
+
86
+ ### Browser Support
87
+ - Chrome (latest)
88
+ - Firefox (latest)
89
+ - Safari (latest)
90
+ - Edge (latest)
91
+
92
+ ---
93
+
94
+ ## Version History
95
+
96
+ - **1.0.0**: Initial stable release with full feature set
97
+ - **0.0.1**: Development version (pre-release)
98
+
99
+ ## Migration Guide
100
+
101
+ ### From 0.0.1 to 1.0.0
102
+
103
+ This is the first stable release. No breaking changes from the development version.
104
+
105
+ ### Updating Your Project
106
+
107
+ Use the template synchronization system to update:
108
+
109
+ ```bash
110
+ npm run sync:template -- --dry-run # Preview changes
111
+ npm run sync:template # Apply updates
112
+ ```
113
+
114
+ ## Support
115
+
116
+ - **Documentation**: [Hugging Face Space](https://huggingface.co/spaces/tfrere/research-article-template)
117
+ - **Issues**: [Community Discussions](https://huggingface.co/spaces/tfrere/research-article-template/discussions)
118
+ - **Contact**: [@tfrere](https://huggingface.co/tfrere)
CONTRIBUTING.md ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to Research Article Template
2
+
3
+ Thank you for your interest in contributing to the Research Article Template! This document provides guidelines and information for contributors.
4
+
5
+ ## 🤝 How to Contribute
6
+
7
+ ### Reporting Issues
8
+
9
+ Before creating an issue, please:
10
+ 1. **Search existing issues** to avoid duplicates
11
+ 2. **Use the issue template** when available
12
+ 3. **Provide detailed information**:
13
+ - Clear description of the problem
14
+ - Steps to reproduce
15
+ - Expected vs actual behavior
16
+ - Environment details (OS, Node.js version, browser)
17
+ - Screenshots if applicable
18
+
19
+ ### Suggesting Features
20
+
21
+ We welcome feature suggestions! Please:
22
+ 1. **Check existing discussions** first
23
+ 2. **Describe the use case** clearly
24
+ 3. **Explain the benefits** for the community
25
+ 4. **Consider implementation complexity**
26
+
27
+ ### Code Contributions
28
+
29
+ #### Getting Started
30
+
31
+ 1. **Fork the repository** on Hugging Face
32
+ 2. **Clone your fork**:
33
+ ```bash
34
+ git clone git@hf.co:spaces/<your-username>/research-article-template
35
+ cd research-article-template
36
+ ```
37
+ 3. **Install dependencies**:
38
+ ```bash
39
+ cd app
40
+ npm install
41
+ ```
42
+ 4. **Create a feature branch**:
43
+ ```bash
44
+ git checkout -b feature/your-feature-name
45
+ ```
46
+
47
+ #### Development Workflow
48
+
49
+ 1. **Make your changes** following our coding standards
50
+ 2. **Test thoroughly**:
51
+ ```bash
52
+ npm run dev # Test locally
53
+ npm run build # Ensure build works
54
+ ```
55
+ 3. **Update documentation** if needed
56
+ 4. **Commit with clear messages**:
57
+ ```bash
58
+ git commit -m "feat: add new component for interactive charts"
59
+ ```
60
+
61
+ #### Pull Request Process
62
+
63
+ 1. **Push your branch**:
64
+ ```bash
65
+ git push origin feature/your-feature-name
66
+ ```
67
+ 2. **Create a Pull Request** with:
68
+ - Clear title and description
69
+ - Reference related issues
70
+ - Screenshots for UI changes
71
+ - Testing instructions
72
+
73
+ ## 📋 Coding Standards
74
+
75
+ ### Code Style
76
+
77
+ - **Use Prettier** for consistent formatting
78
+ - **Follow existing patterns** in the codebase
79
+ - **Write clear, self-documenting code**
80
+ - **Add comments** for complex logic
81
+ - **Use meaningful variable names**
82
+
83
+ ### File Organization
84
+
85
+ - **Components**: Place in `src/components/`
86
+ - **Styles**: Use CSS modules or component-scoped styles
87
+ - **Assets**: Organize in `src/content/assets/`
88
+ - **Documentation**: Update relevant `.mdx` files
89
+
90
+ ### Commit Message Format
91
+
92
+ We follow [Conventional Commits](https://www.conventionalcommits.org/):
93
+
94
+ ```
95
+ type(scope): description
96
+
97
+ feat: add new interactive chart component
98
+ fix: resolve mobile layout issues
99
+ docs: update installation instructions
100
+ style: improve button hover states
101
+ refactor: simplify component structure
102
+ test: add unit tests for utility functions
103
+ ```
104
+
105
+ **Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
106
+
107
+ ## 🧪 Testing
108
+
109
+ ### Manual Testing
110
+
111
+ Before submitting:
112
+ - [ ] Test on different screen sizes
113
+ - [ ] Verify dark/light theme compatibility
114
+ - [ ] Check browser compatibility (Chrome, Firefox, Safari)
115
+ - [ ] Test with different content types
116
+ - [ ] Ensure accessibility standards
117
+
118
+ ### Automated Testing
119
+
120
+ ```bash
121
+ # Run build to catch errors
122
+ npm run build
123
+
124
+ # Test PDF export
125
+ npm run export:pdf
126
+
127
+ # Test LaTeX conversion
128
+ npm run latex:convert
129
+ ```
130
+
131
+ ## 📚 Documentation
132
+
133
+ ### Writing Guidelines
134
+
135
+ - **Use clear, concise language**
136
+ - **Provide examples** for complex features
137
+ - **Include screenshots** for UI changes
138
+ - **Update both English content and code comments**
139
+
140
+ ### Documentation Structure
141
+
142
+ - **README.md**: Project overview and quick start
143
+ - **CONTRIBUTING.md**: This file
144
+ - **Content files**: In `src/content/chapters/demo/`
145
+ - **Component docs**: Inline comments and examples
146
+
147
+ ## 🎯 Areas for Contribution
148
+
149
+ ### High Priority
150
+
151
+ - **Bug fixes** and stability improvements
152
+ - **Accessibility enhancements**
153
+ - **Mobile responsiveness**
154
+ - **Performance optimizations**
155
+ - **Documentation improvements**
156
+
157
+ ### Feature Ideas
158
+
159
+ - **New interactive components**
160
+ - **Additional export formats**
161
+ - **Enhanced LaTeX import**
162
+ - **Theme customization**
163
+ - **Plugin system**
164
+
165
+ ### Community
166
+
167
+ - **Answer questions** in discussions
168
+ - **Share examples** of your work
169
+ - **Write tutorials** and guides
170
+ - **Help with translations**
171
+
172
+ ## 🚫 What Not to Contribute
173
+
174
+ - **Breaking changes** without discussion
175
+ - **Major architectural changes** without approval
176
+ - **Dependencies** that significantly increase bundle size
177
+ - **Features** that don't align with the project's goals
178
+
179
+ ## 📞 Getting Help
180
+
181
+ - **Discussions**: [Community tab](https://huggingface.co/spaces/tfrere/research-article-template/discussions)
182
+ - **Issues**: [Report bugs](https://huggingface.co/spaces/tfrere/research-article-template/discussions?status=open&type=issue)
183
+ - **Contact**: [@tfrere](https://huggingface.co/tfrere) on Hugging Face
184
+
185
+ ## 📄 License
186
+
187
+ By contributing, you agree that your contributions will be licensed under the same [CC-BY-4.0 license](LICENSE) that covers the project.
188
+
189
+ ## 🙏 Recognition
190
+
191
+ Contributors will be:
192
+ - **Listed in acknowledgments** (if desired)
193
+ - **Mentioned in release notes** for significant contributions
194
+ - **Credited** in relevant documentation
195
+
196
+ Thank you for helping make scientific writing more accessible and interactive! 🎉
Dockerfile CHANGED
@@ -1,6 +1,6 @@
1
  # Use an official Node runtime as the base image for building the application
2
  # Build with Playwright (browsers and deps ready)
3
- FROM mcr.microsoft.com/playwright:v1.56.0-jammy AS build
4
 
5
  # Install git, git-lfs, and dependencies for Pandoc (only if ENABLE_LATEX_CONVERSION=true)
6
  RUN apt-get update && apt-get install -y git git-lfs wget && apt-get clean
@@ -24,7 +24,7 @@ RUN npm install
24
  COPY app/ .
25
 
26
  # Conditionally convert LaTeX to MDX if ENABLE_LATEX_CONVERSION=true
27
- ARG ENABLE_LATEX_CONVERSION=false
28
  RUN if [ "$ENABLE_LATEX_CONVERSION" = "true" ]; then \
29
  echo "🔄 LaTeX importer enabled - running latex:convert..."; \
30
  npm run latex:convert; \
@@ -32,10 +32,6 @@ RUN if [ "$ENABLE_LATEX_CONVERSION" = "true" ]; then \
32
  echo "⏭️ LaTeX importer disabled - skipping..."; \
33
  fi
34
 
35
- # Pre-install notion-importer dependencies (for runtime import)
36
- # Note: Notion import is done at RUNTIME (not build time) to access secrets
37
- RUN cd scripts/notion-importer && npm install && cd ../..
38
-
39
  # Ensure `public/data` is a real directory with real files (not a symlink)
40
  # This handles the case where `public/data` is a symlink in the repo, which
41
  # would be broken inside the container after COPY.
@@ -46,32 +42,30 @@ RUN set -e; \
46
  mkdir -p public/data; \
47
  cp -a src/content/assets/data/. public/data/
48
 
49
- # Build the application (with minimal placeholder content)
50
  RUN npm run build
51
 
52
  # Generate the PDF (light theme, full wait)
53
  RUN npm run export:pdf -- --theme=light --wait=full
54
 
55
- # Generate LaTeX export
56
- RUN npm run export:latex
57
 
58
- # Install nginx in the build stage (we'll use this image as final to keep Node.js)
59
- RUN apt-get update && apt-get install -y nginx && apt-get clean && rm -rf /var/lib/apt/lists/*
60
 
61
- # Copy nginx configuration
62
  COPY nginx.conf /etc/nginx/nginx.conf
63
 
64
- # Copy entrypoint script
65
- COPY entrypoint.sh /entrypoint.sh
66
- RUN chmod +x /entrypoint.sh
67
-
68
  # Create necessary directories and set permissions
69
- RUN mkdir -p /var/cache/nginx /var/run /var/log/nginx /var/lib/nginx/body && \
70
- chmod -R 777 /var/cache/nginx /var/run /var/log/nginx /var/lib/nginx /etc/nginx/nginx.conf && \
71
- chmod -R 777 /app
 
 
72
 
73
  # Expose port 8080
74
  EXPOSE 8080
75
 
76
- # Use entrypoint script that handles Notion import if enabled
77
- ENTRYPOINT ["/entrypoint.sh"]
 
1
  # Use an official Node runtime as the base image for building the application
2
  # Build with Playwright (browsers and deps ready)
3
+ FROM mcr.microsoft.com/playwright:v1.55.0-jammy AS build
4
 
5
  # Install git, git-lfs, and dependencies for Pandoc (only if ENABLE_LATEX_CONVERSION=true)
6
  RUN apt-get update && apt-get install -y git git-lfs wget && apt-get clean
 
24
  COPY app/ .
25
 
26
  # Conditionally convert LaTeX to MDX if ENABLE_LATEX_CONVERSION=true
27
+ ARG ENABLE_LATEX_CONVERSION=true
28
  RUN if [ "$ENABLE_LATEX_CONVERSION" = "true" ]; then \
29
  echo "🔄 LaTeX importer enabled - running latex:convert..."; \
30
  npm run latex:convert; \
 
32
  echo "⏭️ LaTeX importer disabled - skipping..."; \
33
  fi
34
 
 
 
 
 
35
  # Ensure `public/data` is a real directory with real files (not a symlink)
36
  # This handles the case where `public/data` is a symlink in the repo, which
37
  # would be broken inside the container after COPY.
 
42
  mkdir -p public/data; \
43
  cp -a src/content/assets/data/. public/data/
44
 
45
+ # Build the application
46
  RUN npm run build
47
 
48
  # Generate the PDF (light theme, full wait)
49
  RUN npm run export:pdf -- --theme=light --wait=full
50
 
51
+ # Use an official Nginx runtime as the base image for serving the application
52
+ FROM nginx:alpine
53
 
54
+ # Copy the built application from the build stage
55
+ COPY --from=build /app/dist /usr/share/nginx/html
56
 
57
+ # Copy a custom Nginx configuration file
58
  COPY nginx.conf /etc/nginx/nginx.conf
59
 
 
 
 
 
60
  # Create necessary directories and set permissions
61
+ RUN mkdir -p /var/cache/nginx /var/run /var/log/nginx && \
62
+ chmod -R 777 /var/cache/nginx /var/run /var/log/nginx /etc/nginx/nginx.conf
63
+
64
+ # Switch to non-root user
65
+ USER nginx
66
 
67
  # Expose port 8080
68
  EXPOSE 8080
69
 
70
+ # Command to run the application
71
+ CMD ["nginx", "-g", "daemon off;"]
LICENSE ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Creative Commons Attribution 4.0 International License
2
+
3
+ Copyright (c) 2024 Thibaud Frere
4
+
5
+ This work is licensed under the Creative Commons Attribution 4.0 International License.
6
+ To view a copy of this license, visit http://creativecommons.org/licenses/by/4.0/
7
+ or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
8
+
9
+ You are free to:
10
+
11
+ Share — copy and redistribute the material in any medium or format
12
+ Adapt — remix, transform, and build upon the material for any purpose, even commercially.
13
+
14
+ The licensor cannot revoke these freedoms as long as you follow the license terms.
15
+
16
+ Under the following terms:
17
+
18
+ Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
19
+
20
+ No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
21
+
22
+ Notices:
23
+
24
+ You do not have to comply with the license for elements of the material in the public domain or where your use is permitted by an applicable exception or limitation.
25
+
26
+ No warranties are given. The license may not give you all of the permissions necessary for your intended use. For example, other rights such as publicity, privacy, or moral rights may limit how you use the material.
27
+
28
+ ---
29
+
30
+ For the source code and technical implementation:
31
+ - The source code is available at: https://huggingface.co/spaces/tfrere/research-article-template
32
+ - Third-party figures and assets are excluded from this license and marked in their captions
33
+ - Dependencies and third-party libraries maintain their respective licenses
app/astro.config.mjs CHANGED
@@ -56,11 +56,13 @@ export default defineConfig({
56
  rehypePlugins: [
57
  rehypeSlug,
58
  [rehypeAutolinkHeadings, { behavior: 'wrap' }],
59
- [rehypeKatex, { trust: true }],
 
 
60
  [rehypeCitation, {
61
  bibliography: 'src/content/bibliography.bib',
62
  linkCitations: true,
63
- csl: "apa"
64
  }],
65
  rehypeReferencesAndFootnotes,
66
  rehypeRestoreAtInCode,
 
56
  rehypePlugins: [
57
  rehypeSlug,
58
  [rehypeAutolinkHeadings, { behavior: 'wrap' }],
59
+ [rehypeKatex, {
60
+ trust: true,
61
+ }],
62
  [rehypeCitation, {
63
  bibliography: 'src/content/bibliography.bib',
64
  linkCitations: true,
65
+ csl: "apa",
66
  }],
67
  rehypeReferencesAndFootnotes,
68
  rehypeRestoreAtInCode,
app/package.json CHANGED
Binary files a/app/package.json and b/app/package.json differ
 
app/public/scripts/color-palettes.js CHANGED
@@ -46,63 +46,95 @@
46
  return { r, g, b: b3 };
47
  };
48
  const oklchToOklab = (L, C, hDeg) => { const h = (hDeg * Math.PI) / 180; return { L, a: C * Math.cos(h), b: C * Math.sin(h) }; };
49
- const oklabToOklch = (L, a, b) => { const C = Math.sqrt(a*a + b*b); let h = Math.atan2(b, a) * 180 / Math.PI; if (h < 0) h += 360; return { L, C, h }; };
50
  const clamp01 = (x) => Math.min(1, Math.max(0, x));
51
  const isInGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
52
- const toHex = ({ r, g, b }) => { const R = Math.round(clamp01(r)*255), G = Math.round(clamp01(g)*255), B = Math.round(clamp01(b)*255); const h = (n) => n.toString(16).padStart(2,'0'); return `#${h(R)}${h(G)}${h(B)}`.toUpperCase(); };
53
- const oklchToHexSafe = (L, C, h) => { let c = C; for (let i=0;i<12;i++){ const { a, b } = oklchToOklab(L,c,h); const rgb = oklabToRgb(L,a,b); if (isInGamut(rgb)) return toHex(rgb); c = Math.max(0, c-0.02);} return toHex(oklabToRgb(L,0,0)); };
54
- const parseCssColorToRgb = (css) => { try { const el = document.createElement('span'); el.style.color = css; document.body.appendChild(el); const cs = getComputedStyle(el).color; document.body.removeChild(el); const m = cs.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (!m) return null; return { r: Number(m[1])/255, g: Number(m[2])/255, b: Number(m[3])/255 }; } catch { return null; } };
 
 
 
 
55
 
56
- const getPrimaryHex = () => {
 
57
  const css = getCssVar('--primary-color');
58
- if (!css) return '#E889AB';
59
- if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(css)) return css.toUpperCase();
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  const rgb = parseCssColorToRgb(css);
61
- if (rgb) return toHex(rgb);
62
- return '#E889AB';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  };
64
  // No count management via CSS anymore; counts are passed directly to the API
65
 
66
  const generators = {
67
- categorical: (baseHex, count) => {
68
- const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
69
- const { r, g, b } = parseHex(baseHex);
70
- const { L, a, b: bb } = rgbToOklab(r,g,b);
71
- const { C, h } = oklabToOklch(L,a,bb);
72
  const L0 = Math.min(0.85, Math.max(0.4, L));
73
  const C0 = Math.min(0.35, Math.max(0.1, C || 0.2));
74
  const total = Math.max(1, Math.min(12, count || 8));
75
  const hueStep = 360 / total;
76
  const results = [];
77
- for (let i=0;i<total;i++) { const hDeg = (h + i*hueStep) % 360; const lVar = ((i % 3) - 1) * 0.04; results.push(oklchToHexSafe(Math.max(0.4, Math.min(0.85, L0 + lVar)), C0, hDeg)); }
 
 
 
 
78
  return results;
79
  },
80
- sequential: (baseHex, count) => {
81
- const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
82
- const { r, g, b } = parseHex(baseHex);
83
- const { L, a, b: bb } = rgbToOklab(r,g,b);
84
- const { C, h } = oklabToOklch(L,a,bb);
85
  const total = Math.max(1, Math.min(12, count || 8));
86
  const startL = Math.max(0.25, L - 0.18);
87
  const endL = Math.min(0.92, L + 0.18);
88
  const cBase = Math.min(0.33, Math.max(0.08, C * 0.9 + 0.06));
89
  const out = [];
90
- for (let i=0;i<total;i++) { const t = total===1 ? 0 : i/(total-1); const lNow = startL*(1-t)+endL*t; const cNow = cBase*(0.85 + 0.15*(1 - Math.abs(0.5 - t)*2)); out.push(oklchToHexSafe(lNow, cNow, h)); }
 
 
 
 
 
91
  return out;
92
  },
93
- diverging: (baseHex, count) => {
94
- const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
95
- const { r, g, b } = parseHex(baseHex);
96
- const baseLab = rgbToOklab(r,g,b);
97
- const baseLch = oklabToOklch(baseLab.L, baseLab.a, baseLab.b);
98
  const total = Math.max(1, Math.min(12, count || 8));
99
 
100
  // Left endpoint: EXACT primary color (no darkening)
101
- const leftLab = baseLab;
102
  // Right endpoint: complement with same L and similar C (clamped safe)
103
- const compH = (baseLch.h + 180) % 360;
104
- const cSafe = Math.min(0.35, Math.max(0.08, baseLch.C));
105
- const rightLab = oklchToOklab(baseLab.L, cSafe, compH);
106
  const whiteLab = { L: 0.98, a: 0, b: 0 }; // center near‑white
107
 
108
  const hexFromOKLab = (L, a, b) => toHex(oklabToRgb(L, a, b));
@@ -152,18 +184,22 @@
152
  let lastSignature = '';
153
 
154
  const updatePalettes = () => {
155
- const primary = getPrimaryHex();
156
- const signature = `${primary}`;
 
157
  if (signature === lastSignature) return;
158
  lastSignature = signature;
159
- try { document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary } })); } catch {}
160
  };
161
 
162
  const bootstrap = () => {
 
163
  updatePalettes();
 
 
164
  const mo = new MutationObserver(() => updatePalettes());
165
  mo.observe(MODE.cssRoot, { attributes: true, attributeFilter: ['style', 'data-theme'] });
166
- setInterval(updatePalettes, 400);
167
  // Utility: choose high-contrast (or softened) text style against an arbitrary background color
168
  const pickTextStyleForBackground = (bgCss, opts = {}) => {
169
  const cssRoot = document.documentElement;
@@ -175,13 +211,13 @@
175
  if (!rgb) return null;
176
  return rgb; // already 0..1
177
  };
178
- const mixRgb01 = (a, b, t) => ({ r: a.r*(1-t)+b.r*t, g: a.g*(1-t)+b.g*t, b: a.b*(1-t)+b.b*t });
179
  const relLum = (rgb) => {
180
  const f = (u) => srgbToLinear(u);
181
- return 0.2126*f(rgb.r) + 0.7152*f(rgb.g) + 0.0722*f(rgb.b);
182
  };
183
  const contrast = (fg, bg) => {
184
- const L1 = relLum(fg), L2 = relLum(bg); const a = Math.max(L1,L2), b = Math.min(L1,L2);
185
  return (a + 0.05) / (b + 0.05);
186
  };
187
  try {
@@ -193,7 +229,7 @@
193
  .filter(x => !!x.rgb);
194
  // Pick the max contrast
195
  let best = candidates[0]; let bestCR = contrast(best.rgb, bg);
196
- for (let i=1;i<candidates.length;i++){
197
  const cr = contrast(candidates[i].rgb, bg);
198
  if (cr > bestCR) { best = candidates[i]; bestCR = cr; }
199
  }
@@ -206,7 +242,7 @@
206
  finalRgb = mixRgb01(best.rgb, mutedRgb, blend);
207
  }
208
  const haloStrength = Math.min(1, Math.max(0, Number(opts.haloStrength == null ? 0.5 : opts.haloStrength)));
209
- const stroke = (best.css === '#000' || best.css.toLowerCase() === 'black') ? `rgba(255,255,255,${0.30 + 0.40*haloStrength})` : `rgba(0,0,0,${0.30 + 0.30*haloStrength})`;
210
  return { fill: toHex(finalRgb), stroke, strokeWidth: (opts.haloWidth == null ? 1 : Number(opts.haloWidth)) };
211
  } catch {
212
  return { fill: getCssVar('--text-color') || '#000', stroke: 'var(--transparent-page-contrast)', strokeWidth: 1 };
@@ -214,14 +250,16 @@
214
  };
215
  window.ColorPalettes = {
216
  refresh: updatePalettes,
217
- notify: () => { try { const primary = getPrimaryHex(); document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary } })); } catch {} },
218
  getPrimary: () => getPrimaryHex(),
 
219
  getColors: (key, count = 6) => {
220
- const primary = getPrimaryHex();
 
221
  const total = Math.max(1, Math.min(12, Number(count) || 6));
222
- if (key === 'categorical') return generators.categorical(primary, total);
223
- if (key === 'sequential') return generators.sequential(primary, total);
224
- if (key === 'diverging') return generators.diverging(primary, total);
225
  return [];
226
  },
227
  getTextStyleForBackground: (bgCss, opts) => pickTextStyleForBackground(bgCss, opts || {}),
 
46
  return { r, g, b: b3 };
47
  };
48
  const oklchToOklab = (L, C, hDeg) => { const h = (hDeg * Math.PI) / 180; return { L, a: C * Math.cos(h), b: C * Math.sin(h) }; };
49
+ const oklabToOklch = (L, a, b) => { const C = Math.sqrt(a * a + b * b); let h = Math.atan2(b, a) * 180 / Math.PI; if (h < 0) h += 360; return { L, C, h }; };
50
  const clamp01 = (x) => Math.min(1, Math.max(0, x));
51
  const isInGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
52
+ const toHex = ({ r, g, b }) => {
53
+ const R = Math.round(clamp01(r) * 255), G = Math.round(clamp01(g) * 255), B = Math.round(clamp01(b) * 255);
54
+ const h = (n) => n.toString(16).padStart(2, '0');
55
+ return `#${h(R)}${h(G)}${h(B)}`.toUpperCase();
56
+ };
57
+ const oklchToHexSafe = (L, C, h) => { let c = C; for (let i = 0; i < 12; i++) { const { a, b } = oklchToOklab(L, c, h); const rgb = oklabToRgb(L, a, b); if (isInGamut(rgb)) return toHex(rgb); c = Math.max(0, c - 0.02); } return toHex(oklabToRgb(L, 0, 0)); };
58
+ const parseCssColorToRgb = (css) => { try { const el = document.createElement('span'); el.style.color = css; document.body.appendChild(el); const cs = getComputedStyle(el).color; document.body.removeChild(el); const m = cs.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (!m) return null; return { r: Number(m[1]) / 255, g: Number(m[2]) / 255, b: Number(m[3]) / 255 }; } catch { return null; } };
59
 
60
+ // Get primary color in OKLCH format to preserve precision
61
+ const getPrimaryOKLCH = () => {
62
  const css = getCssVar('--primary-color');
63
+ if (!css) return null;
64
+
65
+ // For OKLCH colors, return the exact values without conversion
66
+ if (css.includes('oklch')) {
67
+ const oklchMatch = css.match(/oklch\(([^)]+)\)/);
68
+ if (oklchMatch) {
69
+ const values = oklchMatch[1].split(/\s+/).map(v => parseFloat(v.trim()));
70
+ if (values.length >= 3) {
71
+ const [L, C, h] = values;
72
+ return { L, C, h };
73
+ }
74
+ }
75
+ }
76
+
77
+ // For non-OKLCH colors, convert to OKLCH for consistency
78
  const rgb = parseCssColorToRgb(css);
79
+ if (rgb) {
80
+ const { L, a, b } = rgbToOklab(rgb.r, rgb.g, rgb.b);
81
+ const { C, h } = oklabToOklch(L, a, b);
82
+ return { L, C, h };
83
+ }
84
+ return null;
85
+ };
86
+
87
+ // Keep getPrimaryHex for backward compatibility, but now it converts from OKLCH
88
+ const getPrimaryHex = () => {
89
+ const oklch = getPrimaryOKLCH();
90
+ if (!oklch) return null;
91
+
92
+ const { a, b } = oklchToOklab(oklch.L, oklch.C, oklch.h);
93
+ const rgb = oklabToRgb(oklch.L, a, b);
94
+ return toHex(rgb);
95
  };
96
  // No count management via CSS anymore; counts are passed directly to the API
97
 
98
  const generators = {
99
+ categorical: (baseOKLCH, count) => {
100
+ const { L, C, h } = baseOKLCH;
 
 
 
101
  const L0 = Math.min(0.85, Math.max(0.4, L));
102
  const C0 = Math.min(0.35, Math.max(0.1, C || 0.2));
103
  const total = Math.max(1, Math.min(12, count || 8));
104
  const hueStep = 360 / total;
105
  const results = [];
106
+ for (let i = 0; i < total; i++) {
107
+ const hDeg = (h + i * hueStep) % 360;
108
+ const lVar = ((i % 3) - 1) * 0.04;
109
+ results.push(oklchToHexSafe(Math.max(0.4, Math.min(0.85, L0 + lVar)), C0, hDeg));
110
+ }
111
  return results;
112
  },
113
+ sequential: (baseOKLCH, count) => {
114
+ const { L, C, h } = baseOKLCH;
 
 
 
115
  const total = Math.max(1, Math.min(12, count || 8));
116
  const startL = Math.max(0.25, L - 0.18);
117
  const endL = Math.min(0.92, L + 0.18);
118
  const cBase = Math.min(0.33, Math.max(0.08, C * 0.9 + 0.06));
119
  const out = [];
120
+ for (let i = 0; i < total; i++) {
121
+ const t = total === 1 ? 0 : i / (total - 1);
122
+ const lNow = startL * (1 - t) + endL * t;
123
+ const cNow = cBase * (0.85 + 0.15 * (1 - Math.abs(0.5 - t) * 2));
124
+ out.push(oklchToHexSafe(lNow, cNow, h));
125
+ }
126
  return out;
127
  },
128
+ diverging: (baseOKLCH, count) => {
129
+ const { L, C, h } = baseOKLCH;
 
 
 
130
  const total = Math.max(1, Math.min(12, count || 8));
131
 
132
  // Left endpoint: EXACT primary color (no darkening)
133
+ const leftLab = oklchToOklab(L, C, h);
134
  // Right endpoint: complement with same L and similar C (clamped safe)
135
+ const compH = (h + 180) % 360;
136
+ const cSafe = Math.min(0.35, Math.max(0.08, C));
137
+ const rightLab = oklchToOklab(L, cSafe, compH);
138
  const whiteLab = { L: 0.98, a: 0, b: 0 }; // center near‑white
139
 
140
  const hexFromOKLab = (L, a, b) => toHex(oklabToRgb(L, a, b));
 
184
  let lastSignature = '';
185
 
186
  const updatePalettes = () => {
187
+ const primaryOKLCH = getPrimaryOKLCH();
188
+ const primaryHex = getPrimaryHex();
189
+ const signature = `${primaryOKLCH?.L},${primaryOKLCH?.C},${primaryOKLCH?.h}`;
190
  if (signature === lastSignature) return;
191
  lastSignature = signature;
192
+ try { document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary: primaryHex, primaryOKLCH } })); } catch { }
193
  };
194
 
195
  const bootstrap = () => {
196
+ // Initial setup - only run once on page load
197
  updatePalettes();
198
+
199
+ // Observer will handle all subsequent changes
200
  const mo = new MutationObserver(() => updatePalettes());
201
  mo.observe(MODE.cssRoot, { attributes: true, attributeFilter: ['style', 'data-theme'] });
202
+
203
  // Utility: choose high-contrast (or softened) text style against an arbitrary background color
204
  const pickTextStyleForBackground = (bgCss, opts = {}) => {
205
  const cssRoot = document.documentElement;
 
211
  if (!rgb) return null;
212
  return rgb; // already 0..1
213
  };
214
+ const mixRgb01 = (a, b, t) => ({ r: a.r * (1 - t) + b.r * t, g: a.g * (1 - t) + b.g * t, b: a.b * (1 - t) + b.b * t });
215
  const relLum = (rgb) => {
216
  const f = (u) => srgbToLinear(u);
217
+ return 0.2126 * f(rgb.r) + 0.7152 * f(rgb.g) + 0.0722 * f(rgb.b);
218
  };
219
  const contrast = (fg, bg) => {
220
+ const L1 = relLum(fg), L2 = relLum(bg); const a = Math.max(L1, L2), b = Math.min(L1, L2);
221
  return (a + 0.05) / (b + 0.05);
222
  };
223
  try {
 
229
  .filter(x => !!x.rgb);
230
  // Pick the max contrast
231
  let best = candidates[0]; let bestCR = contrast(best.rgb, bg);
232
+ for (let i = 1; i < candidates.length; i++) {
233
  const cr = contrast(candidates[i].rgb, bg);
234
  if (cr > bestCR) { best = candidates[i]; bestCR = cr; }
235
  }
 
242
  finalRgb = mixRgb01(best.rgb, mutedRgb, blend);
243
  }
244
  const haloStrength = Math.min(1, Math.max(0, Number(opts.haloStrength == null ? 0.5 : opts.haloStrength)));
245
+ const stroke = (best.css === '#000' || best.css.toLowerCase() === 'black') ? `rgba(255,255,255,${0.30 + 0.40 * haloStrength})` : `rgba(0,0,0,${0.30 + 0.30 * haloStrength})`;
246
  return { fill: toHex(finalRgb), stroke, strokeWidth: (opts.haloWidth == null ? 1 : Number(opts.haloWidth)) };
247
  } catch {
248
  return { fill: getCssVar('--text-color') || '#000', stroke: 'var(--transparent-page-contrast)', strokeWidth: 1 };
 
250
  };
251
  window.ColorPalettes = {
252
  refresh: updatePalettes,
253
+ notify: () => { try { const primaryOKLCH = getPrimaryOKLCH(); const primaryHex = getPrimaryHex(); document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary: primaryHex, primaryOKLCH } })); } catch { } },
254
  getPrimary: () => getPrimaryHex(),
255
+ getPrimaryOKLCH: () => getPrimaryOKLCH(),
256
  getColors: (key, count = 6) => {
257
+ const primaryOKLCH = getPrimaryOKLCH();
258
+ if (!primaryOKLCH) return [];
259
  const total = Math.max(1, Math.min(12, Number(count) || 6));
260
+ if (key === 'categorical') return generators.categorical(primaryOKLCH, total);
261
+ if (key === 'sequential') return generators.sequential(primaryOKLCH, total);
262
+ if (key === 'diverging') return generators.diverging(primaryOKLCH, total);
263
  return [];
264
  },
265
  getTextStyleForBackground: (bgCss, opts) => pickTextStyleForBackground(bgCss, opts || {}),
app/scripts/notion-importer/.notion-to-md/media/27877f1c-9c9d-804d-9c82-f7b3905578ff_media.json CHANGED
@@ -1,198 +1,3 @@
1
- {
2
- "pageId": "27877f1c-9c9d-804d-9c82-f7b3905578ff",
3
- "lastUpdated": "2025-10-08T13:00:26.065Z",
4
- "mediaEntries": {
5
- "27877f1c-9c9d-8078-b6da-c7a4c67c8f35": {
6
- "mediaInfo": {
7
- "type": "DOWNLOAD",
8
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/69940e63-ba02-4d6b-bc79-81dcab16590b/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466RQAHHWD2%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130015Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQCja3KUJzl59DJB4he%2BZc4EIAdSKZH6PHGkvSc5iTCA5gIhAK1O3OugR1Ap%2Fhuos6U7RbD2KxvfMHmfQXSU6K7IuEKgKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igw0pXUjiFqmEZtM2z8q3AMIdeJHjnKFMXfrzdsbRQMk5j3lsQc7tLMnmEPmHH%2FmX7sZrT4UjUkaKoeSyH%2Bg7wFpYmgvN77x8nGQ0nEQtZAPw2%2F3EH4nPTYF53xRmvyn7%2FMwloMmw7eBz7SIZ3Xk0aPYpbtVRoPUesefx7Jvddzmxu0Q%2FQpw0%2FrRCkLPJAnMdsR8fY3n8s8TBv7cVHHWkjpqgeaNKOD7O7Ej%2FNkVahgG%2BVi%2B8DlcuBG8FNk2EzDNroum71dNW6RzTO3ju65V5xHwLgmRkZRTKS%2FHeolCb01h51d%2BhJY03OORmJCMcOEYzPWg48LVGlFl%2FOA6OAhR%2FQ0eWs%2B1ZzKAz7HOSK6gHOhmcCj%2Bbve0%2FVaH2P708k33m5SQqjZQmfmO0JUCNn2WRPK9uYfSy64QFqpzeEXgAGiaNMQCrWjrLpO1TNcK457IQ4ofuJ9bt3be947z17Fa2pKdcescLFE7GdVtV5L4Nps5%2FoHCl0X1BxCPjpxN8zLRIQZtHFRloU8K6ebOrPFIb4bkjxfC9VidpyS0vnZ1DhFt8ssI1sOwrzRn5Kf%2FRltkKxvJPAp829yFUsrA35VhLZLllGpsdLzGVZS1GWkNqf0UxA5BZhdb9t4qPmp1WbBIufTnxcFuHKdUMxsdfTCxpZnHBjqkASugrWSTfsJxQU%2BvD6fKuut0yaNhG1Zscr6q%2FLX6mZAcKzCrMYWhsql27FMH5lEFZTsvBfNoX2h1ie3MPQw%2B67C4yNLYGIvBL%2BFPBfaw6qimvk48x5JDZBNsxN4fEMufvUM1%2FbemWiZZJSl8V5Q5h0NMxBmZFJkachxllAPWTTVPbSArY%2FYyX3vpKDGMi1fYZYT4IjNycyQDb6nSEKlqbnOjUosj&X-Amz-Signature=0e7a6d2cf89a76128b1e4ecd8e12053deb0a5e802a15e3e8c3d7979e2131fa09&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
9
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8078-b6da-c7a4c67c8f35.png",
10
- "sourceType": "block",
11
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8078-b6da-c7a4c67c8f35.png"
12
- },
13
- "lastEdited": "2025-09-24T09:22:00.000Z",
14
- "createdAt": "2025-09-24T09:36:35.892Z",
15
- "updatedAt": "2025-10-08T13:00:25.689Z"
16
- },
17
- "27877f1c-9c9d-8014-834f-d700b623256b": {
18
- "mediaInfo": {
19
- "type": "DOWNLOAD",
20
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/a11fbd23-10f8-4485-ad35-f3de9d480449/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466RQAHHWD2%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130015Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQCja3KUJzl59DJB4he%2BZc4EIAdSKZH6PHGkvSc5iTCA5gIhAK1O3OugR1Ap%2Fhuos6U7RbD2KxvfMHmfQXSU6K7IuEKgKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igw0pXUjiFqmEZtM2z8q3AMIdeJHjnKFMXfrzdsbRQMk5j3lsQc7tLMnmEPmHH%2FmX7sZrT4UjUkaKoeSyH%2Bg7wFpYmgvN77x8nGQ0nEQtZAPw2%2F3EH4nPTYF53xRmvyn7%2FMwloMmw7eBz7SIZ3Xk0aPYpbtVRoPUesefx7Jvddzmxu0Q%2FQpw0%2FrRCkLPJAnMdsR8fY3n8s8TBv7cVHHWkjpqgeaNKOD7O7Ej%2FNkVahgG%2BVi%2B8DlcuBG8FNk2EzDNroum71dNW6RzTO3ju65V5xHwLgmRkZRTKS%2FHeolCb01h51d%2BhJY03OORmJCMcOEYzPWg48LVGlFl%2FOA6OAhR%2FQ0eWs%2B1ZzKAz7HOSK6gHOhmcCj%2Bbve0%2FVaH2P708k33m5SQqjZQmfmO0JUCNn2WRPK9uYfSy64QFqpzeEXgAGiaNMQCrWjrLpO1TNcK457IQ4ofuJ9bt3be947z17Fa2pKdcescLFE7GdVtV5L4Nps5%2FoHCl0X1BxCPjpxN8zLRIQZtHFRloU8K6ebOrPFIb4bkjxfC9VidpyS0vnZ1DhFt8ssI1sOwrzRn5Kf%2FRltkKxvJPAp829yFUsrA35VhLZLllGpsdLzGVZS1GWkNqf0UxA5BZhdb9t4qPmp1WbBIufTnxcFuHKdUMxsdfTCxpZnHBjqkASugrWSTfsJxQU%2BvD6fKuut0yaNhG1Zscr6q%2FLX6mZAcKzCrMYWhsql27FMH5lEFZTsvBfNoX2h1ie3MPQw%2B67C4yNLYGIvBL%2BFPBfaw6qimvk48x5JDZBNsxN4fEMufvUM1%2FbemWiZZJSl8V5Q5h0NMxBmZFJkachxllAPWTTVPbSArY%2FYyX3vpKDGMi1fYZYT4IjNycyQDb6nSEKlqbnOjUosj&X-Amz-Signature=9145e81d85d35d531ffb6bbabad25d5846aa559613d371bc2880f6d02529b799&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
21
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8014-834f-d700b623256b.png",
22
- "sourceType": "block",
23
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8014-834f-d700b623256b.png"
24
- },
25
- "lastEdited": "2025-09-24T09:22:00.000Z",
26
- "createdAt": "2025-09-24T09:36:35.899Z",
27
- "updatedAt": "2025-10-08T13:00:25.703Z"
28
- },
29
- "27877f1c-9c9d-808d-9c6d-fae817ac8868": {
30
- "mediaInfo": {
31
- "type": "DOWNLOAD",
32
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/9b549e26-b527-4ad5-b1ad-3ed049747090/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466X3VEST5Q%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130020Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQC4Th0nobtVjj0GnPkA6aNua8I5iyMPP5wm3FPgOcNrpQIhAM6Cd1AyvJOWLCdgyh%2F9xxRIzhQ8ZEYJPtDoTzf9dVzsKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igwbq%2Fmpsk4PhTeoeU8q3ANUdDY8Un1V3sp1KnXAZQmxQggeyUGmjkp%2BsBgML%2Bh3jLesSMzdlQPFwlTBbqh74KOfU7NmNfHFLiHqWLrpeLClymtDa9BGRQAnfNGuT%2BUXXxp5dSoeOsbUJInqYhW4FsjUBRNw%2FSAslpIJKfTrmcN81%2FIUQNc0TRtVmiiXKPFUCNfWErOAQlec%2B6mNZCJYY1EbzUg1tPFNYYLpoiNb45%2F0t1VlsyeG6WlkA2KFXybguL2BmlWms%2BC%2BDeANHe2r%2Bq0h9JVtz%2BYBB3CzzQdeerNfGtyml%2BTz5DiBvv0h9F5FNcqJ4Uf1CbLGaRVIlCXLHam3xV3Fk%2FpYYQerVcK6t6YWWeRO0x92Uj1TyF1H4aCPO%2BuAjxDdY0WJfdLROdRvwH69zTdBYb26Lo7h2T2MnY%2FjJ4UNGPf0CWoGMVU9BJCO6%2Bm7RySlYPw1Axogb7clhfZAUYsM%2FhBI01n5OLo2mIl1dMcIxnZNc%2FFxp3njz6Jpth%2Fu0KOe8X%2FPTgM9S58jNUVTWHYd3bEm54SfyUlEIwmSVHkdHAvZw8QfhIC8DAJULbBkKHe6nRKxvGqo1UZW2OY0%2BKpOu7kPBwFl13lcqjMeSy%2F%2BvpZDgwHonNSKYiYRrlffBfL%2BUFWdiijebzDHpJnHBjqkAfpE6DXG5ducvq1TeQISVLspLOKXhLJ0Jn1auWbgWOv02xnmSsxVrzmbEGcfsyZArqlrtxdg9N9yH30onEToRB0OnUGFrpiazcXfmdl7JS0Cn3mL4FiEHdFC%2B2lUkrwFbKVX0NhXsjQRmkeUUtT7dXPTDyzpPIxoyghxvJsm6OswiSouBbg4NB6EQaFjgxRw9KEg79jjKbCgyYvpKNs56YVN9kvk&X-Amz-Signature=b9e650906f6978d3d01caebf5329c1571bfa97d53d88a3c571eaaa7b618970d2&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
33
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-808d-9c6d-fae817ac8868.png",
34
- "sourceType": "block",
35
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-808d-9c6d-fae817ac8868.png"
36
- },
37
- "lastEdited": "2025-09-24T09:22:00.000Z",
38
- "createdAt": "2025-09-24T09:36:35.943Z",
39
- "updatedAt": "2025-10-08T13:00:25.727Z"
40
- },
41
- "27877f1c-9c9d-8075-ae2e-dc24fe9296ca": {
42
- "mediaInfo": {
43
- "type": "DOWNLOAD",
44
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/943ada15-c18b-4434-ac96-cca2440119db/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466X3VEST5Q%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130020Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQC4Th0nobtVjj0GnPkA6aNua8I5iyMPP5wm3FPgOcNrpQIhAM6Cd1AyvJOWLCdgyh%2F9xxRIzhQ8ZEYJPtDoTzf9dVzsKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igwbq%2Fmpsk4PhTeoeU8q3ANUdDY8Un1V3sp1KnXAZQmxQggeyUGmjkp%2BsBgML%2Bh3jLesSMzdlQPFwlTBbqh74KOfU7NmNfHFLiHqWLrpeLClymtDa9BGRQAnfNGuT%2BUXXxp5dSoeOsbUJInqYhW4FsjUBRNw%2FSAslpIJKfTrmcN81%2FIUQNc0TRtVmiiXKPFUCNfWErOAQlec%2B6mNZCJYY1EbzUg1tPFNYYLpoiNb45%2F0t1VlsyeG6WlkA2KFXybguL2BmlWms%2BC%2BDeANHe2r%2Bq0h9JVtz%2BYBB3CzzQdeerNfGtyml%2BTz5DiBvv0h9F5FNcqJ4Uf1CbLGaRVIlCXLHam3xV3Fk%2FpYYQerVcK6t6YWWeRO0x92Uj1TyF1H4aCPO%2BuAjxDdY0WJfdLROdRvwH69zTdBYb26Lo7h2T2MnY%2FjJ4UNGPf0CWoGMVU9BJCO6%2Bm7RySlYPw1Axogb7clhfZAUYsM%2FhBI01n5OLo2mIl1dMcIxnZNc%2FFxp3njz6Jpth%2Fu0KOe8X%2FPTgM9S58jNUVTWHYd3bEm54SfyUlEIwmSVHkdHAvZw8QfhIC8DAJULbBkKHe6nRKxvGqo1UZW2OY0%2BKpOu7kPBwFl13lcqjMeSy%2F%2BvpZDgwHonNSKYiYRrlffBfL%2BUFWdiijebzDHpJnHBjqkAfpE6DXG5ducvq1TeQISVLspLOKXhLJ0Jn1auWbgWOv02xnmSsxVrzmbEGcfsyZArqlrtxdg9N9yH30onEToRB0OnUGFrpiazcXfmdl7JS0Cn3mL4FiEHdFC%2B2lUkrwFbKVX0NhXsjQRmkeUUtT7dXPTDyzpPIxoyghxvJsm6OswiSouBbg4NB6EQaFjgxRw9KEg79jjKbCgyYvpKNs56YVN9kvk&X-Amz-Signature=f6c6aeafa758351188cf92575d7adbac6d4438d61891b377861cceebe4699c16&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
45
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8075-ae2e-dc24fe9296ca.png",
46
- "sourceType": "block",
47
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8075-ae2e-dc24fe9296ca.png"
48
- },
49
- "lastEdited": "2025-09-24T09:22:00.000Z",
50
- "createdAt": "2025-09-24T09:36:36.095Z",
51
- "updatedAt": "2025-10-08T13:00:25.926Z"
52
- },
53
- "27877f1c-9c9d-801d-841a-e35011491566": {
54
- "mediaInfo": {
55
- "type": "DOWNLOAD",
56
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/3a912fb2-01eb-46d2-9687-41ffbb5b0446/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466Y2XCJFFW%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130015Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQDGr1WaO6mypVW00YGoLx4XK5HeMm7tg4Wxi4tRLT0sBQIhAJZB9sfEWXoL6QQ8WnamF77mePTGmWqiZjYqIQ1Ba5kMKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgyvfgauHIg52DwLqjMq3AOxwogx8uxNPXYdZuG3Giz0pcRPcVYGSzx%2FsCHi3wTJAjMOnK7oq5bOcT%2Fl2UzIP2n1RWhZZWYAbDJFjDsGnnxxjP1dA4W%2BG2JxtQjoyKDV24YvvyoKp%2B8c%2BVuzeJYc%2FAp0wja9UZjjmB6We9vy73l%2F0lzOEnuDWGvA3Wz7HN72nCAxRWfFw3VXoLc12NJA7jMXJpMTJ0kQhR54IefMuCgXQRkP9ThY944aJBcJDVVW2oPCliR0sNKWOwxmzYLfqTaCfVYnYWij0W2PFYWTl8O8Fz%2F6tJQeBzIzYZkQH%2B%2FID9QHo2H91PyVglygKK5nFfSUGmu5%2BlBHNZhF0tQ%2BkkSiJBO%2Fzpzh1l3tJ5%2FSDTS3OkCq9CgQS1LlOZ%2FWVGRa0dO%2BPKuLt2kSE3g%2BdENrh0uHMXL16JnD8tihVBQjDVDteb9ty3wYric3oz7UIdTbcFFhBvLNK2iaTTxgVVe8Iwec1Yh5%2Br70PVEtQ2RGpeJRNF%2FTCdNajTXZecTA4gfj3Dep1uEbLH6wJzKpoYff5ewJZaQ5NqIKjBbwZlP0ZsdmirA3vCFTiBuA%2FtxyQutgfCfqtxDzRYtRLdYtuXHnOJX19k0dW1qKTx%2FvJHjGhd%2BF7DDuSjbWdZfyMNBtvTCCpZnHBjqkAXWtzxhDxy981YikbANxxcBAJxQM7hXL4Lmn9JZH3hI2Z46Dd1xpYDzpq%2Bv0TjPTSsilTsyySr4C8rXZuw4BTrdyjlduhH34%2FNp16EC1u7PlP2iNue5eMeLdERldacspGGFk9eApo%2FSCPEUJdHN%2B514Zr0nBCSnGWEy9najlnY9KFSWwfrPpbEz0Z2MLkADQVvGJ4N1Aa7S4MU9m9WAxOA%2BBxMyX&X-Amz-Signature=fdbb11308e72b72824529e50a6386bf06785d0576e5c27d6cf6f609380614733&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
57
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-801d-841a-e35011491566.png",
58
- "sourceType": "block",
59
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-801d-841a-e35011491566.png"
60
- },
61
- "lastEdited": "2025-09-24T09:22:00.000Z",
62
- "createdAt": "2025-09-24T09:36:36.121Z",
63
- "updatedAt": "2025-10-08T13:00:25.898Z"
64
- },
65
- "27877f1c-9c9d-8048-9b7e-db4fa7485915": {
66
- "mediaInfo": {
67
- "type": "DOWNLOAD",
68
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/58651851-13c8-4e99-92e3-857c8e3d305b/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466X3VEST5Q%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130020Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQC4Th0nobtVjj0GnPkA6aNua8I5iyMPP5wm3FPgOcNrpQIhAM6Cd1AyvJOWLCdgyh%2F9xxRIzhQ8ZEYJPtDoTzf9dVzsKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igwbq%2Fmpsk4PhTeoeU8q3ANUdDY8Un1V3sp1KnXAZQmxQggeyUGmjkp%2BsBgML%2Bh3jLesSMzdlQPFwlTBbqh74KOfU7NmNfHFLiHqWLrpeLClymtDa9BGRQAnfNGuT%2BUXXxp5dSoeOsbUJInqYhW4FsjUBRNw%2FSAslpIJKfTrmcN81%2FIUQNc0TRtVmiiXKPFUCNfWErOAQlec%2B6mNZCJYY1EbzUg1tPFNYYLpoiNb45%2F0t1VlsyeG6WlkA2KFXybguL2BmlWms%2BC%2BDeANHe2r%2Bq0h9JVtz%2BYBB3CzzQdeerNfGtyml%2BTz5DiBvv0h9F5FNcqJ4Uf1CbLGaRVIlCXLHam3xV3Fk%2FpYYQerVcK6t6YWWeRO0x92Uj1TyF1H4aCPO%2BuAjxDdY0WJfdLROdRvwH69zTdBYb26Lo7h2T2MnY%2FjJ4UNGPf0CWoGMVU9BJCO6%2Bm7RySlYPw1Axogb7clhfZAUYsM%2FhBI01n5OLo2mIl1dMcIxnZNc%2FFxp3njz6Jpth%2Fu0KOe8X%2FPTgM9S58jNUVTWHYd3bEm54SfyUlEIwmSVHkdHAvZw8QfhIC8DAJULbBkKHe6nRKxvGqo1UZW2OY0%2BKpOu7kPBwFl13lcqjMeSy%2F%2BvpZDgwHonNSKYiYRrlffBfL%2BUFWdiijebzDHpJnHBjqkAfpE6DXG5ducvq1TeQISVLspLOKXhLJ0Jn1auWbgWOv02xnmSsxVrzmbEGcfsyZArqlrtxdg9N9yH30onEToRB0OnUGFrpiazcXfmdl7JS0Cn3mL4FiEHdFC%2B2lUkrwFbKVX0NhXsjQRmkeUUtT7dXPTDyzpPIxoyghxvJsm6OswiSouBbg4NB6EQaFjgxRw9KEg79jjKbCgyYvpKNs56YVN9kvk&X-Amz-Signature=f13847b038a80822deb40d3e0873722dc40cb227be4a6edfdb70d27be0794729&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
69
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8048-9b7e-db4fa7485915.png",
70
- "sourceType": "block",
71
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8048-9b7e-db4fa7485915.png"
72
- },
73
- "lastEdited": "2025-09-24T09:22:00.000Z",
74
- "createdAt": "2025-09-24T09:36:36.136Z",
75
- "updatedAt": "2025-10-08T13:00:25.917Z"
76
- },
77
- "27877f1c-9c9d-804d-bd0a-e0b1c15e504f": {
78
- "mediaInfo": {
79
- "type": "DOWNLOAD",
80
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/3fbc7e6c-8ec0-4ef5-9161-7aef75930323/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466RQAHHWD2%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130014Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQCja3KUJzl59DJB4he%2BZc4EIAdSKZH6PHGkvSc5iTCA5gIhAK1O3OugR1Ap%2Fhuos6U7RbD2KxvfMHmfQXSU6K7IuEKgKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igw0pXUjiFqmEZtM2z8q3AMIdeJHjnKFMXfrzdsbRQMk5j3lsQc7tLMnmEPmHH%2FmX7sZrT4UjUkaKoeSyH%2Bg7wFpYmgvN77x8nGQ0nEQtZAPw2%2F3EH4nPTYF53xRmvyn7%2FMwloMmw7eBz7SIZ3Xk0aPYpbtVRoPUesefx7Jvddzmxu0Q%2FQpw0%2FrRCkLPJAnMdsR8fY3n8s8TBv7cVHHWkjpqgeaNKOD7O7Ej%2FNkVahgG%2BVi%2B8DlcuBG8FNk2EzDNroum71dNW6RzTO3ju65V5xHwLgmRkZRTKS%2FHeolCb01h51d%2BhJY03OORmJCMcOEYzPWg48LVGlFl%2FOA6OAhR%2FQ0eWs%2B1ZzKAz7HOSK6gHOhmcCj%2Bbve0%2FVaH2P708k33m5SQqjZQmfmO0JUCNn2WRPK9uYfSy64QFqpzeEXgAGiaNMQCrWjrLpO1TNcK457IQ4ofuJ9bt3be947z17Fa2pKdcescLFE7GdVtV5L4Nps5%2FoHCl0X1BxCPjpxN8zLRIQZtHFRloU8K6ebOrPFIb4bkjxfC9VidpyS0vnZ1DhFt8ssI1sOwrzRn5Kf%2FRltkKxvJPAp829yFUsrA35VhLZLllGpsdLzGVZS1GWkNqf0UxA5BZhdb9t4qPmp1WbBIufTnxcFuHKdUMxsdfTCxpZnHBjqkASugrWSTfsJxQU%2BvD6fKuut0yaNhG1Zscr6q%2FLX6mZAcKzCrMYWhsql27FMH5lEFZTsvBfNoX2h1ie3MPQw%2B67C4yNLYGIvBL%2BFPBfaw6qimvk48x5JDZBNsxN4fEMufvUM1%2FbemWiZZJSl8V5Q5h0NMxBmZFJkachxllAPWTTVPbSArY%2FYyX3vpKDGMi1fYZYT4IjNycyQDb6nSEKlqbnOjUosj&X-Amz-Signature=fd7ef992dc4f3a290a5f04a0a640fe2828b5a33487682c4956913fe674c9c017&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
81
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-804d-bd0a-e0b1c15e504f.png",
82
- "sourceType": "block",
83
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-804d-bd0a-e0b1c15e504f.png"
84
- },
85
- "lastEdited": "2025-09-24T09:22:00.000Z",
86
- "createdAt": "2025-09-24T09:36:36.244Z",
87
- "updatedAt": "2025-10-08T13:00:26.009Z"
88
- },
89
- "27877f1c-9c9d-80b9-8cfb-f0a6aaaa8760": {
90
- "mediaInfo": {
91
- "type": "DOWNLOAD",
92
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/d3899f55-47ba-45b0-be0f-9abc5bb4f247/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466RQAHHWD2%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130015Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQCja3KUJzl59DJB4he%2BZc4EIAdSKZH6PHGkvSc5iTCA5gIhAK1O3OugR1Ap%2Fhuos6U7RbD2KxvfMHmfQXSU6K7IuEKgKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igw0pXUjiFqmEZtM2z8q3AMIdeJHjnKFMXfrzdsbRQMk5j3lsQc7tLMnmEPmHH%2FmX7sZrT4UjUkaKoeSyH%2Bg7wFpYmgvN77x8nGQ0nEQtZAPw2%2F3EH4nPTYF53xRmvyn7%2FMwloMmw7eBz7SIZ3Xk0aPYpbtVRoPUesefx7Jvddzmxu0Q%2FQpw0%2FrRCkLPJAnMdsR8fY3n8s8TBv7cVHHWkjpqgeaNKOD7O7Ej%2FNkVahgG%2BVi%2B8DlcuBG8FNk2EzDNroum71dNW6RzTO3ju65V5xHwLgmRkZRTKS%2FHeolCb01h51d%2BhJY03OORmJCMcOEYzPWg48LVGlFl%2FOA6OAhR%2FQ0eWs%2B1ZzKAz7HOSK6gHOhmcCj%2Bbve0%2FVaH2P708k33m5SQqjZQmfmO0JUCNn2WRPK9uYfSy64QFqpzeEXgAGiaNMQCrWjrLpO1TNcK457IQ4ofuJ9bt3be947z17Fa2pKdcescLFE7GdVtV5L4Nps5%2FoHCl0X1BxCPjpxN8zLRIQZtHFRloU8K6ebOrPFIb4bkjxfC9VidpyS0vnZ1DhFt8ssI1sOwrzRn5Kf%2FRltkKxvJPAp829yFUsrA35VhLZLllGpsdLzGVZS1GWkNqf0UxA5BZhdb9t4qPmp1WbBIufTnxcFuHKdUMxsdfTCxpZnHBjqkASugrWSTfsJxQU%2BvD6fKuut0yaNhG1Zscr6q%2FLX6mZAcKzCrMYWhsql27FMH5lEFZTsvBfNoX2h1ie3MPQw%2B67C4yNLYGIvBL%2BFPBfaw6qimvk48x5JDZBNsxN4fEMufvUM1%2FbemWiZZJSl8V5Q5h0NMxBmZFJkachxllAPWTTVPbSArY%2FYyX3vpKDGMi1fYZYT4IjNycyQDb6nSEKlqbnOjUosj&X-Amz-Signature=0e702a4db1c4dabdd69b83b59535958cd39a3e2906332aa607e61864797bca59&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
93
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80b9-8cfb-f0a6aaaa8760.png",
94
- "sourceType": "block",
95
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80b9-8cfb-f0a6aaaa8760.png"
96
- },
97
- "lastEdited": "2025-09-24T09:22:00.000Z",
98
- "createdAt": "2025-09-24T09:36:36.247Z",
99
- "updatedAt": "2025-10-08T13:00:26.000Z"
100
- },
101
- "27877f1c-9c9d-80aa-b968-c54c9fe7e5d7": {
102
- "mediaInfo": {
103
- "type": "DOWNLOAD",
104
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/1f240ffd-b4d7-422a-9a81-0aabda1f6c16/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466Y2XCJFFW%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130015Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQDGr1WaO6mypVW00YGoLx4XK5HeMm7tg4Wxi4tRLT0sBQIhAJZB9sfEWXoL6QQ8WnamF77mePTGmWqiZjYqIQ1Ba5kMKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgyvfgauHIg52DwLqjMq3AOxwogx8uxNPXYdZuG3Giz0pcRPcVYGSzx%2FsCHi3wTJAjMOnK7oq5bOcT%2Fl2UzIP2n1RWhZZWYAbDJFjDsGnnxxjP1dA4W%2BG2JxtQjoyKDV24YvvyoKp%2B8c%2BVuzeJYc%2FAp0wja9UZjjmB6We9vy73l%2F0lzOEnuDWGvA3Wz7HN72nCAxRWfFw3VXoLc12NJA7jMXJpMTJ0kQhR54IefMuCgXQRkP9ThY944aJBcJDVVW2oPCliR0sNKWOwxmzYLfqTaCfVYnYWij0W2PFYWTl8O8Fz%2F6tJQeBzIzYZkQH%2B%2FID9QHo2H91PyVglygKK5nFfSUGmu5%2BlBHNZhF0tQ%2BkkSiJBO%2Fzpzh1l3tJ5%2FSDTS3OkCq9CgQS1LlOZ%2FWVGRa0dO%2BPKuLt2kSE3g%2BdENrh0uHMXL16JnD8tihVBQjDVDteb9ty3wYric3oz7UIdTbcFFhBvLNK2iaTTxgVVe8Iwec1Yh5%2Br70PVEtQ2RGpeJRNF%2FTCdNajTXZecTA4gfj3Dep1uEbLH6wJzKpoYff5ewJZaQ5NqIKjBbwZlP0ZsdmirA3vCFTiBuA%2FtxyQutgfCfqtxDzRYtRLdYtuXHnOJX19k0dW1qKTx%2FvJHjGhd%2BF7DDuSjbWdZfyMNBtvTCCpZnHBjqkAXWtzxhDxy981YikbANxxcBAJxQM7hXL4Lmn9JZH3hI2Z46Dd1xpYDzpq%2Bv0TjPTSsilTsyySr4C8rXZuw4BTrdyjlduhH34%2FNp16EC1u7PlP2iNue5eMeLdERldacspGGFk9eApo%2FSCPEUJdHN%2B514Zr0nBCSnGWEy9najlnY9KFSWwfrPpbEz0Z2MLkADQVvGJ4N1Aa7S4MU9m9WAxOA%2BBxMyX&X-Amz-Signature=c353bf2bf0d4ef95c66531df5c779035a90add76e2c286e9e26d41c1a6526eb9&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
105
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80aa-b968-c54c9fe7e5d7.png",
106
- "sourceType": "block",
107
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80aa-b968-c54c9fe7e5d7.png"
108
- },
109
- "lastEdited": "2025-09-24T09:22:00.000Z",
110
- "createdAt": "2025-09-24T09:36:36.247Z",
111
- "updatedAt": "2025-10-08T13:00:26.061Z"
112
- },
113
- "27877f1c-9c9d-8013-b668-f14bd1ac0ec0": {
114
- "mediaInfo": {
115
- "type": "DOWNLOAD",
116
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/cb01b334-89a5-436f-a141-b7c310d25b57/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466Y2XCJFFW%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130015Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQDGr1WaO6mypVW00YGoLx4XK5HeMm7tg4Wxi4tRLT0sBQIhAJZB9sfEWXoL6QQ8WnamF77mePTGmWqiZjYqIQ1Ba5kMKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgyvfgauHIg52DwLqjMq3AOxwogx8uxNPXYdZuG3Giz0pcRPcVYGSzx%2FsCHi3wTJAjMOnK7oq5bOcT%2Fl2UzIP2n1RWhZZWYAbDJFjDsGnnxxjP1dA4W%2BG2JxtQjoyKDV24YvvyoKp%2B8c%2BVuzeJYc%2FAp0wja9UZjjmB6We9vy73l%2F0lzOEnuDWGvA3Wz7HN72nCAxRWfFw3VXoLc12NJA7jMXJpMTJ0kQhR54IefMuCgXQRkP9ThY944aJBcJDVVW2oPCliR0sNKWOwxmzYLfqTaCfVYnYWij0W2PFYWTl8O8Fz%2F6tJQeBzIzYZkQH%2B%2FID9QHo2H91PyVglygKK5nFfSUGmu5%2BlBHNZhF0tQ%2BkkSiJBO%2Fzpzh1l3tJ5%2FSDTS3OkCq9CgQS1LlOZ%2FWVGRa0dO%2BPKuLt2kSE3g%2BdENrh0uHMXL16JnD8tihVBQjDVDteb9ty3wYric3oz7UIdTbcFFhBvLNK2iaTTxgVVe8Iwec1Yh5%2Br70PVEtQ2RGpeJRNF%2FTCdNajTXZecTA4gfj3Dep1uEbLH6wJzKpoYff5ewJZaQ5NqIKjBbwZlP0ZsdmirA3vCFTiBuA%2FtxyQutgfCfqtxDzRYtRLdYtuXHnOJX19k0dW1qKTx%2FvJHjGhd%2BF7DDuSjbWdZfyMNBtvTCCpZnHBjqkAXWtzxhDxy981YikbANxxcBAJxQM7hXL4Lmn9JZH3hI2Z46Dd1xpYDzpq%2Bv0TjPTSsilTsyySr4C8rXZuw4BTrdyjlduhH34%2FNp16EC1u7PlP2iNue5eMeLdERldacspGGFk9eApo%2FSCPEUJdHN%2B514Zr0nBCSnGWEy9najlnY9KFSWwfrPpbEz0Z2MLkADQVvGJ4N1Aa7S4MU9m9WAxOA%2BBxMyX&X-Amz-Signature=ff0a0599c70fd9f202d55e4a7e887de8d8727724550d0e9faf6df28e0e9b7696&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
117
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8013-b668-f14bd1ac0ec0.png",
118
- "sourceType": "block",
119
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8013-b668-f14bd1ac0ec0.png"
120
- },
121
- "lastEdited": "2025-09-24T09:22:00.000Z",
122
- "createdAt": "2025-09-24T09:36:36.250Z",
123
- "updatedAt": "2025-10-08T13:00:26.056Z"
124
- },
125
- "27877f1c-9c9d-80e9-b729-dbd328930bed": {
126
- "mediaInfo": {
127
- "type": "DOWNLOAD",
128
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/9d723e06-252f-44e3-844e-1410fa92667a/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466RQAHHWD2%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130015Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQCja3KUJzl59DJB4he%2BZc4EIAdSKZH6PHGkvSc5iTCA5gIhAK1O3OugR1Ap%2Fhuos6U7RbD2KxvfMHmfQXSU6K7IuEKgKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igw0pXUjiFqmEZtM2z8q3AMIdeJHjnKFMXfrzdsbRQMk5j3lsQc7tLMnmEPmHH%2FmX7sZrT4UjUkaKoeSyH%2Bg7wFpYmgvN77x8nGQ0nEQtZAPw2%2F3EH4nPTYF53xRmvyn7%2FMwloMmw7eBz7SIZ3Xk0aPYpbtVRoPUesefx7Jvddzmxu0Q%2FQpw0%2FrRCkLPJAnMdsR8fY3n8s8TBv7cVHHWkjpqgeaNKOD7O7Ej%2FNkVahgG%2BVi%2B8DlcuBG8FNk2EzDNroum71dNW6RzTO3ju65V5xHwLgmRkZRTKS%2FHeolCb01h51d%2BhJY03OORmJCMcOEYzPWg48LVGlFl%2FOA6OAhR%2FQ0eWs%2B1ZzKAz7HOSK6gHOhmcCj%2Bbve0%2FVaH2P708k33m5SQqjZQmfmO0JUCNn2WRPK9uYfSy64QFqpzeEXgAGiaNMQCrWjrLpO1TNcK457IQ4ofuJ9bt3be947z17Fa2pKdcescLFE7GdVtV5L4Nps5%2FoHCl0X1BxCPjpxN8zLRIQZtHFRloU8K6ebOrPFIb4bkjxfC9VidpyS0vnZ1DhFt8ssI1sOwrzRn5Kf%2FRltkKxvJPAp829yFUsrA35VhLZLllGpsdLzGVZS1GWkNqf0UxA5BZhdb9t4qPmp1WbBIufTnxcFuHKdUMxsdfTCxpZnHBjqkASugrWSTfsJxQU%2BvD6fKuut0yaNhG1Zscr6q%2FLX6mZAcKzCrMYWhsql27FMH5lEFZTsvBfNoX2h1ie3MPQw%2B67C4yNLYGIvBL%2BFPBfaw6qimvk48x5JDZBNsxN4fEMufvUM1%2FbemWiZZJSl8V5Q5h0NMxBmZFJkachxllAPWTTVPbSArY%2FYyX3vpKDGMi1fYZYT4IjNycyQDb6nSEKlqbnOjUosj&X-Amz-Signature=4faf0d241e315ba80e5f56bb4cb410229c15e429fcab990fd1ebc2b17e2e4388&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
129
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80e9-b729-dbd328930bed.png",
130
- "sourceType": "block",
131
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80e9-b729-dbd328930bed.png"
132
- },
133
- "lastEdited": "2025-09-24T09:22:00.000Z",
134
- "createdAt": "2025-09-24T09:36:36.251Z",
135
- "updatedAt": "2025-10-08T13:00:26.048Z"
136
- },
137
- "27877f1c-9c9d-80a9-b4d0-f2129716632d": {
138
- "mediaInfo": {
139
- "type": "DOWNLOAD",
140
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/137c2c51-6f89-437f-ba45-3a844c747b7a/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466W5FHSQK6%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130018Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJGMEQCIAhuEUuJdQLuRcUIqWrlF%2FxJVRmDyl6u61kVLhtjlfZUAiBV%2Fg6BRYZGv8gjl2KUiGhGUm6oydw9R%2BGbL4YBC8orxyqIBAi9%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F8BEAAaDDYzNzQyMzE4MzgwNSIM3uBXzGO9Ig0qNGY4KtwD8SFvKEBtLvytudTAIznpwZ0LlbKJs50UtNCuHXZMaMCWVzh3bh5s1YUDv9F8QzpxpmwoEHNA0zdieOQ0RTayznMP%2FQonTMVpq110QUSpjw0oiOQAObBO%2FZhOW3eVIvyERdtG8hJ%2FFdtOx68HiwShjXQNIx%2FTLP%2FyKsamFu97bNFKLLbXaKEQL%2FPeYBMfUh1FOikwzUsWtnX6xkIyDZlzyvR5XfiR93IkhPKAe3tRogdfHErVfaMhWD4CLC3CotugLqYLChLu2FEgjljFpEHc5u5od5FnVy9AUpcA9X21VVFEM5ZdQ2e3RAB6wIMQvIaYTHHCnFFfouA8nRqcEVzWysN3qwh2ilXM%2FkIfZN7fRIxbBSPLyUIVcJpZNwbzs8d5bs3qvzM9G8FkzDvtYML3JOdEzF4LuxAAJJLAqhTTQ1PhnB4lalWW%2BzGBluxdvxfidxr7w8b74x1A6sr2RRg204TCYmbv8epUSs%2BRfkjbpQb25T59MrJnDDkvR3vwM0uRiYOcx7BIJoJQ9NABbDhUdQivPKSx%2FWfUI4qzy2Qu3ylcwrudYUrkObXxW8EpCrpCr3q7hWCPkKfvLhioYojN04XKEoAr9eJNJKYu8%2BGtS2%2FHsFqf94Soxz4NYa8wsaWZxwY6pgEneyRps7aPn1nFrZvbD3DUmmNVnG7Ot90eZ9AeaSCgE0a8LPo2EBJqYduCTOFx0ZS31WSpymmuKKyjsI94sx8u%2F5Mmvyc%2F6g6F%2FSGga8Z7F5NIPVciGCTsa2iZFLblK4LGKDfvx5RgoyuAatccTNlNYca%2F5w9iQ1JXOu83l39OW1O54Xr5rX96kIiak60h5LxJgIHU3zeBdD4UeGH39LaRoJyMksdX&X-Amz-Signature=3e45baae308e0004d8e62bc03d6e459d6310e19ae4935db52491727bc24231cb&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
141
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80a9-b4d0-f2129716632d.png",
142
- "sourceType": "block",
143
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80a9-b4d0-f2129716632d.png"
144
- },
145
- "lastEdited": "2025-09-24T09:22:00.000Z",
146
- "createdAt": "2025-09-24T09:36:36.257Z",
147
- "updatedAt": "2025-10-08T13:00:26.048Z"
148
- },
149
- "27877f1c-9c9d-80b6-be07-e8646502f82a": {
150
- "mediaInfo": {
151
- "type": "DOWNLOAD",
152
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/843a4edd-435d-45ca-8212-1af31305fb3a/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466RQAHHWD2%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130015Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQCja3KUJzl59DJB4he%2BZc4EIAdSKZH6PHGkvSc5iTCA5gIhAK1O3OugR1Ap%2Fhuos6U7RbD2KxvfMHmfQXSU6K7IuEKgKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igw0pXUjiFqmEZtM2z8q3AMIdeJHjnKFMXfrzdsbRQMk5j3lsQc7tLMnmEPmHH%2FmX7sZrT4UjUkaKoeSyH%2Bg7wFpYmgvN77x8nGQ0nEQtZAPw2%2F3EH4nPTYF53xRmvyn7%2FMwloMmw7eBz7SIZ3Xk0aPYpbtVRoPUesefx7Jvddzmxu0Q%2FQpw0%2FrRCkLPJAnMdsR8fY3n8s8TBv7cVHHWkjpqgeaNKOD7O7Ej%2FNkVahgG%2BVi%2B8DlcuBG8FNk2EzDNroum71dNW6RzTO3ju65V5xHwLgmRkZRTKS%2FHeolCb01h51d%2BhJY03OORmJCMcOEYzPWg48LVGlFl%2FOA6OAhR%2FQ0eWs%2B1ZzKAz7HOSK6gHOhmcCj%2Bbve0%2FVaH2P708k33m5SQqjZQmfmO0JUCNn2WRPK9uYfSy64QFqpzeEXgAGiaNMQCrWjrLpO1TNcK457IQ4ofuJ9bt3be947z17Fa2pKdcescLFE7GdVtV5L4Nps5%2FoHCl0X1BxCPjpxN8zLRIQZtHFRloU8K6ebOrPFIb4bkjxfC9VidpyS0vnZ1DhFt8ssI1sOwrzRn5Kf%2FRltkKxvJPAp829yFUsrA35VhLZLllGpsdLzGVZS1GWkNqf0UxA5BZhdb9t4qPmp1WbBIufTnxcFuHKdUMxsdfTCxpZnHBjqkASugrWSTfsJxQU%2BvD6fKuut0yaNhG1Zscr6q%2FLX6mZAcKzCrMYWhsql27FMH5lEFZTsvBfNoX2h1ie3MPQw%2B67C4yNLYGIvBL%2BFPBfaw6qimvk48x5JDZBNsxN4fEMufvUM1%2FbemWiZZJSl8V5Q5h0NMxBmZFJkachxllAPWTTVPbSArY%2FYyX3vpKDGMi1fYZYT4IjNycyQDb6nSEKlqbnOjUosj&X-Amz-Signature=6b41be5cc09cf3fae5304c63a3291650e24c6ad9285ba21202c90a186c8be390&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
153
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80b6-be07-e8646502f82a.png",
154
- "sourceType": "block",
155
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80b6-be07-e8646502f82a.png"
156
- },
157
- "lastEdited": "2025-09-24T09:22:00.000Z",
158
- "createdAt": "2025-09-24T09:36:36.274Z",
159
- "updatedAt": "2025-10-08T13:00:25.995Z"
160
- },
161
- "27877f1c-9c9d-808f-b712-c7c608da3fc6": {
162
- "mediaInfo": {
163
- "type": "DOWNLOAD",
164
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/33327526-41c3-4e9c-b4ee-c7cc3c23c7ca/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466Y2XCJFFW%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130015Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQDGr1WaO6mypVW00YGoLx4XK5HeMm7tg4Wxi4tRLT0sBQIhAJZB9sfEWXoL6QQ8WnamF77mePTGmWqiZjYqIQ1Ba5kMKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgyvfgauHIg52DwLqjMq3AOxwogx8uxNPXYdZuG3Giz0pcRPcVYGSzx%2FsCHi3wTJAjMOnK7oq5bOcT%2Fl2UzIP2n1RWhZZWYAbDJFjDsGnnxxjP1dA4W%2BG2JxtQjoyKDV24YvvyoKp%2B8c%2BVuzeJYc%2FAp0wja9UZjjmB6We9vy73l%2F0lzOEnuDWGvA3Wz7HN72nCAxRWfFw3VXoLc12NJA7jMXJpMTJ0kQhR54IefMuCgXQRkP9ThY944aJBcJDVVW2oPCliR0sNKWOwxmzYLfqTaCfVYnYWij0W2PFYWTl8O8Fz%2F6tJQeBzIzYZkQH%2B%2FID9QHo2H91PyVglygKK5nFfSUGmu5%2BlBHNZhF0tQ%2BkkSiJBO%2Fzpzh1l3tJ5%2FSDTS3OkCq9CgQS1LlOZ%2FWVGRa0dO%2BPKuLt2kSE3g%2BdENrh0uHMXL16JnD8tihVBQjDVDteb9ty3wYric3oz7UIdTbcFFhBvLNK2iaTTxgVVe8Iwec1Yh5%2Br70PVEtQ2RGpeJRNF%2FTCdNajTXZecTA4gfj3Dep1uEbLH6wJzKpoYff5ewJZaQ5NqIKjBbwZlP0ZsdmirA3vCFTiBuA%2FtxyQutgfCfqtxDzRYtRLdYtuXHnOJX19k0dW1qKTx%2FvJHjGhd%2BF7DDuSjbWdZfyMNBtvTCCpZnHBjqkAXWtzxhDxy981YikbANxxcBAJxQM7hXL4Lmn9JZH3hI2Z46Dd1xpYDzpq%2Bv0TjPTSsilTsyySr4C8rXZuw4BTrdyjlduhH34%2FNp16EC1u7PlP2iNue5eMeLdERldacspGGFk9eApo%2FSCPEUJdHN%2B514Zr0nBCSnGWEy9najlnY9KFSWwfrPpbEz0Z2MLkADQVvGJ4N1Aa7S4MU9m9WAxOA%2BBxMyX&X-Amz-Signature=68518895319bebfef33cc5c5786ea73b408d55f058fe1b07a6e057f3167389dc&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
165
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-808f-b712-c7c608da3fc6.png",
166
- "sourceType": "block",
167
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-808f-b712-c7c608da3fc6.png"
168
- },
169
- "lastEdited": "2025-09-24T09:22:00.000Z",
170
- "createdAt": "2025-09-24T09:36:36.274Z",
171
- "updatedAt": "2025-10-08T13:00:25.863Z"
172
- },
173
- "27877f1c-9c9d-8031-ac8d-c5678af1bdd5": {
174
- "mediaInfo": {
175
- "type": "DOWNLOAD",
176
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/16831579-d887-4894-b36a-d4df8a134653/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466X3VEST5Q%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130020Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQC4Th0nobtVjj0GnPkA6aNua8I5iyMPP5wm3FPgOcNrpQIhAM6Cd1AyvJOWLCdgyh%2F9xxRIzhQ8ZEYJPtDoTzf9dVzsKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igwbq%2Fmpsk4PhTeoeU8q3ANUdDY8Un1V3sp1KnXAZQmxQggeyUGmjkp%2BsBgML%2Bh3jLesSMzdlQPFwlTBbqh74KOfU7NmNfHFLiHqWLrpeLClymtDa9BGRQAnfNGuT%2BUXXxp5dSoeOsbUJInqYhW4FsjUBRNw%2FSAslpIJKfTrmcN81%2FIUQNc0TRtVmiiXKPFUCNfWErOAQlec%2B6mNZCJYY1EbzUg1tPFNYYLpoiNb45%2F0t1VlsyeG6WlkA2KFXybguL2BmlWms%2BC%2BDeANHe2r%2Bq0h9JVtz%2BYBB3CzzQdeerNfGtyml%2BTz5DiBvv0h9F5FNcqJ4Uf1CbLGaRVIlCXLHam3xV3Fk%2FpYYQerVcK6t6YWWeRO0x92Uj1TyF1H4aCPO%2BuAjxDdY0WJfdLROdRvwH69zTdBYb26Lo7h2T2MnY%2FjJ4UNGPf0CWoGMVU9BJCO6%2Bm7RySlYPw1Axogb7clhfZAUYsM%2FhBI01n5OLo2mIl1dMcIxnZNc%2FFxp3njz6Jpth%2Fu0KOe8X%2FPTgM9S58jNUVTWHYd3bEm54SfyUlEIwmSVHkdHAvZw8QfhIC8DAJULbBkKHe6nRKxvGqo1UZW2OY0%2BKpOu7kPBwFl13lcqjMeSy%2F%2BvpZDgwHonNSKYiYRrlffBfL%2BUFWdiijebzDHpJnHBjqkAfpE6DXG5ducvq1TeQISVLspLOKXhLJ0Jn1auWbgWOv02xnmSsxVrzmbEGcfsyZArqlrtxdg9N9yH30onEToRB0OnUGFrpiazcXfmdl7JS0Cn3mL4FiEHdFC%2B2lUkrwFbKVX0NhXsjQRmkeUUtT7dXPTDyzpPIxoyghxvJsm6OswiSouBbg4NB6EQaFjgxRw9KEg79jjKbCgyYvpKNs56YVN9kvk&X-Amz-Signature=0e07c27075a366f43cf237c80660a72ba43bdbad3486e7f1f9e3a12afdfcc7f0&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
177
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8031-ac8d-c5678af1bdd5.png",
178
- "sourceType": "block",
179
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8031-ac8d-c5678af1bdd5.png"
180
- },
181
- "lastEdited": "2025-09-24T09:22:00.000Z",
182
- "createdAt": "2025-09-24T09:36:36.276Z",
183
- "updatedAt": "2025-10-08T13:00:25.920Z"
184
- },
185
- "27877f1c-9c9d-80e7-a500-fb79cebde7e3": {
186
- "mediaInfo": {
187
- "type": "DOWNLOAD",
188
- "originalUrl": "https://prod-files-secure.s3.us-west-2.amazonaws.com/6fe77f1c-9c9d-81b1-b174-00031f2bc702/0458ed42-9805-4c8f-9d7e-c675474a626e/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466X3VEST5Q%2F20251008%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251008T130020Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjECQaCXVzLXdlc3QtMiJIMEYCIQC4Th0nobtVjj0GnPkA6aNua8I5iyMPP5wm3FPgOcNrpQIhAM6Cd1AyvJOWLCdgyh%2F9xxRIzhQ8ZEYJPtDoTzf9dVzsKogECL3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igwbq%2Fmpsk4PhTeoeU8q3ANUdDY8Un1V3sp1KnXAZQmxQggeyUGmjkp%2BsBgML%2Bh3jLesSMzdlQPFwlTBbqh74KOfU7NmNfHFLiHqWLrpeLClymtDa9BGRQAnfNGuT%2BUXXxp5dSoeOsbUJInqYhW4FsjUBRNw%2FSAslpIJKfTrmcN81%2FIUQNc0TRtVmiiXKPFUCNfWErOAQlec%2B6mNZCJYY1EbzUg1tPFNYYLpoiNb45%2F0t1VlsyeG6WlkA2KFXybguL2BmlWms%2BC%2BDeANHe2r%2Bq0h9JVtz%2BYBB3CzzQdeerNfGtyml%2BTz5DiBvv0h9F5FNcqJ4Uf1CbLGaRVIlCXLHam3xV3Fk%2FpYYQerVcK6t6YWWeRO0x92Uj1TyF1H4aCPO%2BuAjxDdY0WJfdLROdRvwH69zTdBYb26Lo7h2T2MnY%2FjJ4UNGPf0CWoGMVU9BJCO6%2Bm7RySlYPw1Axogb7clhfZAUYsM%2FhBI01n5OLo2mIl1dMcIxnZNc%2FFxp3njz6Jpth%2Fu0KOe8X%2FPTgM9S58jNUVTWHYd3bEm54SfyUlEIwmSVHkdHAvZw8QfhIC8DAJULbBkKHe6nRKxvGqo1UZW2OY0%2BKpOu7kPBwFl13lcqjMeSy%2F%2BvpZDgwHonNSKYiYRrlffBfL%2BUFWdiijebzDHpJnHBjqkAfpE6DXG5ducvq1TeQISVLspLOKXhLJ0Jn1auWbgWOv02xnmSsxVrzmbEGcfsyZArqlrtxdg9N9yH30onEToRB0OnUGFrpiazcXfmdl7JS0Cn3mL4FiEHdFC%2B2lUkrwFbKVX0NhXsjQRmkeUUtT7dXPTDyzpPIxoyghxvJsm6OswiSouBbg4NB6EQaFjgxRw9KEg79jjKbCgyYvpKNs56YVN9kvk&X-Amz-Signature=e43c1f35e9930e144c0b2b6098457be4848a723301fd78f3ff27d94f87ccbebb&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
189
- "localPath": "/Users/thibaudfrere/Documents/work-projects/huggingface/research-article-template/app/scripts/notion-importer/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80e7-a500-fb79cebde7e3.png",
190
- "sourceType": "block",
191
- "transformedPath": "/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80e7-a500-fb79cebde7e3.png"
192
- },
193
- "lastEdited": "2025-09-24T09:22:00.000Z",
194
- "createdAt": "2025-09-24T09:36:36.448Z",
195
- "updatedAt": "2025-10-08T13:00:26.065Z"
196
- }
197
- }
198
- }
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c282e7bcb40ee2caafda422b3614d996d6023dbb4bbe10f96521348ee151aeb0
3
+ size 36969
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/scripts/notion-importer/README.md CHANGED
@@ -4,25 +4,6 @@ Complete Notion to MDX (Markdown + JSX) importer optimized for Astro with advanc
4
 
5
  ## 🚀 Quick Start
6
 
7
- ### Method 1: Using NOTION_PAGE_ID (Recommended)
8
-
9
- ```bash
10
- # Install dependencies
11
- npm install
12
-
13
- # Setup environment variables
14
- cp env.example .env
15
- # Edit .env with your Notion token and page ID
16
-
17
- # Complete Notion → MDX conversion (fetches title/slug automatically)
18
- NOTION_TOKEN=secret_xxx NOTION_PAGE_ID=abc123 node index.mjs
19
-
20
- # Or use .env file
21
- node index.mjs
22
- ```
23
-
24
- ### Method 2: Using pages.json (Legacy)
25
-
26
  ```bash
27
  # Install dependencies
28
  npm install
@@ -31,18 +12,7 @@ npm install
31
  cp env.example .env
32
  # Edit .env with your Notion token
33
 
34
- # Configure pages in input/pages.json
35
- # {
36
- # "pages": [
37
- # {
38
- # "id": "your-page-id",
39
- # "title": "Title",
40
- # "slug": "slug"
41
- # }
42
- # ]
43
- # }
44
-
45
- # Complete Notion → MDX conversion
46
  node index.mjs
47
 
48
  # For step-by-step debugging
@@ -73,7 +43,7 @@ notion-importer/
73
  ### 🎯 **Advanced Media Handling**
74
  - **Local download**: Automatic download of all Notion media (images, files, PDFs)
75
  - **Path transformation**: Smart path conversion for web accessibility
76
- - **Image components**: Automatic conversion to Astro `Image` components with zoom/download
77
  - **Media organization**: Structured media storage by page ID
78
 
79
  ### 🧮 **Interactive Components**
@@ -121,7 +91,7 @@ published: "2024-01-15"
121
  tableOfContentsAutoCollapse: true
122
  ---
123
 
124
- import Image from '../components/Image.astro';
125
  import Note from '../components/Note.astro';
126
  import gettingStartedImage from './media/getting-started/image1.png';
127
 
 
4
 
5
  ## 🚀 Quick Start
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ```bash
8
  # Install dependencies
9
  npm install
 
12
  cp env.example .env
13
  # Edit .env with your Notion token
14
 
15
+ # Complete Notion MDX conversion with all features
 
 
 
 
 
 
 
 
 
 
 
16
  node index.mjs
17
 
18
  # For step-by-step debugging
 
43
  ### 🎯 **Advanced Media Handling**
44
  - **Local download**: Automatic download of all Notion media (images, files, PDFs)
45
  - **Path transformation**: Smart path conversion for web accessibility
46
+ - **Figure components**: Automatic conversion to Astro `Figure` components with zoom/download
47
  - **Media organization**: Structured media storage by page ID
48
 
49
  ### 🧮 **Interactive Components**
 
91
  tableOfContentsAutoCollapse: true
92
  ---
93
 
94
+ import Figure from '../components/Figure.astro';
95
  import Note from '../components/Note.astro';
96
  import gettingStartedImage from './media/getting-started/image1.png';
97
 
app/scripts/notion-importer/env.example CHANGED
@@ -1,2 +1,72 @@
1
- NOTION_TOKEN=ntn_xxx
2
- NOTION_PAGE_ID=xxx
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Notion to MDX Toolkit - Environment Variables
2
+ # Copy this file to .env and fill in your actual values
3
+
4
+ # ===========================================
5
+ # NOTION API CONFIGURATION
6
+ # ===========================================
7
+
8
+ # Your Notion Integration Token
9
+ # Get this from: https://www.notion.so/my-integrations
10
+ # Format: secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
11
+ NOTION_TOKEN=secret_your_notion_integration_token_here
12
+
13
+ # ===========================================
14
+ # OPTIONAL CONFIGURATION
15
+ # ===========================================
16
+
17
+ # Custom output directory (optional)
18
+ # Default: ./output
19
+ # OUTPUT_DIR=./my-custom-output
20
+
21
+ # Custom input configuration file (optional)
22
+ # Default: ./input/pages.json
23
+ # INPUT_CONFIG=./my-pages.json
24
+
25
+ # ===========================================
26
+ # USAGE EXAMPLES
27
+ # ===========================================
28
+
29
+ # 1. Basic usage:
30
+ # NOTION_TOKEN=secret_xxx node index.mjs
31
+
32
+ # 2. With custom paths:
33
+ # NOTION_TOKEN=secret_xxx OUTPUT_DIR=./converted node index.mjs
34
+
35
+ # 3. Test access to a page:
36
+ # NOTION_TOKEN=secret_xxx node test-access.mjs
37
+
38
+ # ===========================================
39
+ # SETUP INSTRUCTIONS
40
+ # ===========================================
41
+
42
+ # 1. Create a Notion integration:
43
+ # - Go to https://www.notion.so/my-integrations
44
+ # - Click "New integration"
45
+ # - Give it a name (e.g., "MDX Converter")
46
+ # - Select your workspace
47
+ # - Click "Submit"
48
+ # - Copy the "Internal Integration Token"
49
+
50
+ # 2. Share your Notion pages with the integration:
51
+ # - Open your Notion page
52
+ # - Click "Share" (top right)
53
+ # - Click "Invite"
54
+ # - Search for your integration name
55
+ # - Select it and give "Can read content" permission
56
+ # - Click "Invite"
57
+
58
+ # 3. Configure your pages in input/pages.json:
59
+ # {
60
+ # "pages": [
61
+ # {
62
+ # "id": "your-notion-page-id",
63
+ # "title": "Page Title",
64
+ # "slug": "page-slug"
65
+ # }
66
+ # ]
67
+ # }
68
+
69
+ # 4. Run the conversion:
70
+ # cp env.example .env
71
+ # # Edit .env with your actual token
72
+ # node index.mjs --clean
app/scripts/notion-importer/index.mjs CHANGED
@@ -6,10 +6,9 @@ import { fileURLToPath } from 'url';
6
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
7
  import { convertNotionToMarkdown } from './notion-converter.mjs';
8
  import { convertToMdx } from './mdx-converter.mjs';
9
- import { Client } from '@notionhq/client';
10
 
11
- // Load environment variables from .env file (but don't override existing ones)
12
- config({ override: false });
13
 
14
  const __filename = fileURLToPath(import.meta.url);
15
  const __dirname = dirname(__filename);
@@ -29,8 +28,7 @@ function parseArgs() {
29
  clean: false,
30
  notionOnly: false,
31
  mdxOnly: false,
32
- token: process.env.NOTION_TOKEN,
33
- pageId: process.env.NOTION_PAGE_ID
34
  };
35
 
36
  for (const arg of args) {
@@ -40,8 +38,6 @@ function parseArgs() {
40
  config.output = arg.split('=')[1];
41
  } else if (arg.startsWith('--token=')) {
42
  config.token = arg.split('=')[1];
43
- } else if (arg.startsWith('--page-id=')) {
44
- config.pageId = arg.split('=')[1];
45
  } else if (arg === '--clean') {
46
  config.clean = true;
47
  } else if (arg === '--notion-only') {
@@ -127,54 +123,6 @@ function readPagesConfig(inputFile) {
127
  }
128
  }
129
 
130
- /**
131
- * Create a temporary pages.json from NOTION_PAGE_ID environment variable
132
- * Extracts title and generates slug from the Notion page
133
- */
134
- async function createPagesConfigFromEnv(pageId, token, outputPath) {
135
- try {
136
- console.log('🔍 Fetching page info from Notion API...');
137
- const notion = new Client({ auth: token });
138
- const page = await notion.pages.retrieve({ page_id: pageId });
139
-
140
- // Extract title
141
- let title = 'Article';
142
- if (page.properties.title && page.properties.title.title && page.properties.title.title.length > 0) {
143
- title = page.properties.title.title[0].plain_text;
144
- } else if (page.properties.Name && page.properties.Name.title && page.properties.Name.title.length > 0) {
145
- title = page.properties.Name.title[0].plain_text;
146
- }
147
-
148
- // Generate slug from title
149
- const slug = title
150
- .toLowerCase()
151
- .replace(/[^\w\s-]/g, '')
152
- .replace(/\s+/g, '-')
153
- .replace(/-+/g, '-')
154
- .trim();
155
-
156
- console.log(` ✅ Found page: "${title}" (slug: ${slug})`);
157
-
158
- // Create pages config
159
- const pagesConfig = {
160
- pages: [{
161
- id: pageId,
162
- title: title,
163
- slug: slug
164
- }]
165
- };
166
-
167
- // Write to temporary file
168
- writeFileSync(outputPath, JSON.stringify(pagesConfig, null, 4));
169
- console.log(` ✅ Created temporary pages config`);
170
-
171
- return pagesConfig;
172
- } catch (error) {
173
- console.error(`❌ Error fetching page from Notion: ${error.message}`);
174
- throw error;
175
- }
176
- }
177
-
178
  function copyToAstroContent(outputDir) {
179
  console.log('📋 Copying MDX files to Astro content directory...');
180
 
@@ -188,9 +136,7 @@ function copyToAstroContent(outputDir) {
188
  const mdxFiles = files.filter(file => file.endsWith('.mdx'));
189
  if (mdxFiles.length > 0) {
190
  const mdxFile = join(outputDir, mdxFiles[0]); // Take the first MDX file
191
- // Read and write instead of copy to avoid EPERM issues
192
- const mdxContent = readFileSync(mdxFile, 'utf8');
193
- writeFileSync(ASTRO_CONTENT_PATH, mdxContent);
194
  console.log(` ✅ Copied MDX to ${ASTRO_CONTENT_PATH}`);
195
  }
196
 
@@ -253,24 +199,6 @@ async function main() {
253
  console.log('========================');
254
 
255
  try {
256
- // Prepare input config file
257
- let inputConfigFile = config.input;
258
- let pageIdFromEnv = null;
259
-
260
- // If NOTION_PAGE_ID is provided via env var, create temporary pages.json
261
- if (config.pageId && config.token) {
262
- console.log('✨ Using NOTION_PAGE_ID from environment variable');
263
- const tempConfigPath = join(config.output, '.temp-pages.json');
264
- ensureDirectory(config.output);
265
- await createPagesConfigFromEnv(config.pageId, config.token, tempConfigPath);
266
- inputConfigFile = tempConfigPath;
267
- pageIdFromEnv = config.pageId;
268
- } else if (!existsSync(config.input)) {
269
- console.error(`❌ No NOTION_PAGE_ID environment variable and no pages.json found at: ${config.input}`);
270
- console.log('💡 Either set NOTION_PAGE_ID env var or create input/pages.json');
271
- process.exit(1);
272
- }
273
-
274
  if (config.clean) {
275
  console.log('🧹 Cleaning output directory...');
276
  await cleanDirectory(config.output);
@@ -285,7 +213,7 @@ async function main() {
285
  } else if (config.notionOnly) {
286
  // Only convert Notion to Markdown
287
  console.log('📄 Notion conversion only mode');
288
- await convertNotionToMarkdown(inputConfigFile, config.output, config.token);
289
 
290
  } else {
291
  // Full workflow
@@ -293,13 +221,13 @@ async function main() {
293
 
294
  // Step 1: Convert Notion to Markdown
295
  console.log('\n📄 Step 1: Converting Notion pages to Markdown...');
296
- await convertNotionToMarkdown(inputConfigFile, config.output, config.token);
297
 
298
  // Step 2: Convert Markdown to MDX with Notion metadata
299
  console.log('\n📝 Step 2: Converting Markdown to MDX...');
300
- const pagesConfig = readPagesConfig(inputConfigFile);
301
  const firstPage = pagesConfig.pages && pagesConfig.pages.length > 0 ? pagesConfig.pages[0] : null;
302
- const pageId = pageIdFromEnv || (firstPage ? firstPage.id : null);
303
  await convertToMdx(config.output, config.output, pageId, config.token);
304
 
305
  // Step 3: Copy to Astro content directory
 
6
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
7
  import { convertNotionToMarkdown } from './notion-converter.mjs';
8
  import { convertToMdx } from './mdx-converter.mjs';
 
9
 
10
+ // Load environment variables from .env file
11
+ config();
12
 
13
  const __filename = fileURLToPath(import.meta.url);
14
  const __dirname = dirname(__filename);
 
28
  clean: false,
29
  notionOnly: false,
30
  mdxOnly: false,
31
+ token: process.env.NOTION_TOKEN
 
32
  };
33
 
34
  for (const arg of args) {
 
38
  config.output = arg.split('=')[1];
39
  } else if (arg.startsWith('--token=')) {
40
  config.token = arg.split('=')[1];
 
 
41
  } else if (arg === '--clean') {
42
  config.clean = true;
43
  } else if (arg === '--notion-only') {
 
123
  }
124
  }
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  function copyToAstroContent(outputDir) {
127
  console.log('📋 Copying MDX files to Astro content directory...');
128
 
 
136
  const mdxFiles = files.filter(file => file.endsWith('.mdx'));
137
  if (mdxFiles.length > 0) {
138
  const mdxFile = join(outputDir, mdxFiles[0]); // Take the first MDX file
139
+ copyFileSync(mdxFile, ASTRO_CONTENT_PATH);
 
 
140
  console.log(` ✅ Copied MDX to ${ASTRO_CONTENT_PATH}`);
141
  }
142
 
 
199
  console.log('========================');
200
 
201
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  if (config.clean) {
203
  console.log('🧹 Cleaning output directory...');
204
  await cleanDirectory(config.output);
 
213
  } else if (config.notionOnly) {
214
  // Only convert Notion to Markdown
215
  console.log('📄 Notion conversion only mode');
216
+ await convertNotionToMarkdown(config.input, config.output, config.token);
217
 
218
  } else {
219
  // Full workflow
 
221
 
222
  // Step 1: Convert Notion to Markdown
223
  console.log('\n📄 Step 1: Converting Notion pages to Markdown...');
224
+ await convertNotionToMarkdown(config.input, config.output, config.token);
225
 
226
  // Step 2: Convert Markdown to MDX with Notion metadata
227
  console.log('\n📝 Step 2: Converting Markdown to MDX...');
228
+ const pagesConfig = readPagesConfig(config.input);
229
  const firstPage = pagesConfig.pages && pagesConfig.pages.length > 0 ? pagesConfig.pages[0] : null;
230
+ const pageId = firstPage ? firstPage.id : null;
231
  await convertToMdx(config.output, config.output, pageId, config.token);
232
 
233
  // Step 3: Copy to Astro content directory
app/scripts/notion-importer/input/pages.json CHANGED
@@ -1,9 +1,3 @@
1
- {
2
- "pages": [
3
- {
4
- "id": "27877f1c9c9d804d9c82f7b3905578ff",
5
- "title": "The Smol Training Guide",
6
- "slug": "smol-training-guide"
7
- }
8
- ]
9
- }
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2d51fba4ce9b05562f5df611a150e3cd702b487d2e608441318336556e0f248a
3
+ size 188
 
 
 
 
 
 
app/scripts/notion-importer/mdx-converter.mjs CHANGED
@@ -122,12 +122,12 @@ function addComponentImports(content) {
122
  }
123
 
124
  /**
125
- * Transform Notion images to Image components
126
  * @param {string} content - MDX content
127
- * @returns {string} - Content with Image components
128
  */
129
  function transformImages(content) {
130
- console.log(' 🖼️ Transforming images to Image components...');
131
 
132
  let hasImages = false;
133
 
@@ -163,8 +163,8 @@ function transformImages(content) {
163
  : cleaned;
164
  };
165
 
166
- // Create Image component with import
167
- const createImageComponent = (src, alt = '', caption = '') => {
168
  const cleanSrc = cleanSrcPath(src);
169
 
170
  // Skip PDF URLs and external URLs - they should remain as links only
@@ -177,7 +177,7 @@ function transformImages(content) {
177
 
178
  const varName = generateImageVarName(cleanSrc);
179
  imageImports.set(cleanSrc, varName);
180
- usedComponents.add('Image');
181
 
182
  const props = [];
183
  props.push(`src={${varName}}`);
@@ -187,30 +187,30 @@ function transformImages(content) {
187
  if (alt) props.push(`alt="${alt}"`);
188
  if (caption) props.push(`caption={'${caption}'}`);
189
 
190
- return `<Image\n ${props.join('\n ')}\n/>`;
191
  };
192
 
193
  // Transform markdown images: ![alt](src)
194
  content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
195
  const cleanSrc = cleanSrcPath(src);
196
- const cleanAlt = cleanAltText(alt || 'Image');
197
  hasImages = true;
198
 
199
- return createImageComponent(cleanSrc, cleanAlt);
200
  });
201
 
202
  // Transform images with captions (Notion sometimes adds captions as separate text)
203
  content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)\s*\n\s*([^\n]+)/g, (match, alt, src, caption) => {
204
  const cleanSrc = cleanSrcPath(src);
205
- const cleanAlt = cleanAltText(alt || 'Image');
206
  const cleanCap = cleanCaption(caption);
207
  hasImages = true;
208
 
209
- return createImageComponent(cleanSrc, cleanAlt, cleanCap);
210
  });
211
 
212
  if (hasImages) {
213
- console.log(' ✅ Image components with imports will be created');
214
  }
215
 
216
  return content;
 
122
  }
123
 
124
  /**
125
+ * Transform Notion images to Figure components
126
  * @param {string} content - MDX content
127
+ * @returns {string} - Content with Figure components
128
  */
129
  function transformImages(content) {
130
+ console.log(' 🖼️ Transforming images to Figure components...');
131
 
132
  let hasImages = false;
133
 
 
163
  : cleaned;
164
  };
165
 
166
+ // Create Figure component with import
167
+ const createFigureComponent = (src, alt = '', caption = '') => {
168
  const cleanSrc = cleanSrcPath(src);
169
 
170
  // Skip PDF URLs and external URLs - they should remain as links only
 
177
 
178
  const varName = generateImageVarName(cleanSrc);
179
  imageImports.set(cleanSrc, varName);
180
+ usedComponents.add('Figure');
181
 
182
  const props = [];
183
  props.push(`src={${varName}}`);
 
187
  if (alt) props.push(`alt="${alt}"`);
188
  if (caption) props.push(`caption={'${caption}'}`);
189
 
190
+ return `<Figure\n ${props.join('\n ')}\n/>`;
191
  };
192
 
193
  // Transform markdown images: ![alt](src)
194
  content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
195
  const cleanSrc = cleanSrcPath(src);
196
+ const cleanAlt = cleanAltText(alt || 'Figure');
197
  hasImages = true;
198
 
199
+ return createFigureComponent(cleanSrc, cleanAlt);
200
  });
201
 
202
  // Transform images with captions (Notion sometimes adds captions as separate text)
203
  content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)\s*\n\s*([^\n]+)/g, (match, alt, src, caption) => {
204
  const cleanSrc = cleanSrcPath(src);
205
+ const cleanAlt = cleanAltText(alt || 'Figure');
206
  const cleanCap = cleanCaption(caption);
207
  hasImages = true;
208
 
209
+ return createFigureComponent(cleanSrc, cleanAlt, cleanCap);
210
  });
211
 
212
  if (hasImages) {
213
+ console.log(' ✅ Figure components with imports will be created');
214
  }
215
 
216
  return content;
app/scripts/notion-importer/notion-converter.mjs CHANGED
@@ -10,8 +10,8 @@ import { fileURLToPath } from 'url';
10
  import { postProcessMarkdown } from './post-processor.mjs';
11
  import { createCustomCodeRenderer } from './custom-code-renderer.mjs';
12
 
13
- // Load environment variables from .env file (but don't override existing ones)
14
- config({ override: false });
15
 
16
  const __filename = fileURLToPath(import.meta.url);
17
  const __dirname = dirname(__filename);
 
10
  import { postProcessMarkdown } from './post-processor.mjs';
11
  import { createCustomCodeRenderer } from './custom-code-renderer.mjs';
12
 
13
+ // Load environment variables from .env file
14
+ config();
15
 
16
  const __filename = fileURLToPath(import.meta.url);
17
  const __dirname = dirname(__filename);
app/scripts/sync-template.mjs ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Template synchronization script for research-article-template
5
+ *
6
+ * This script:
7
+ * 1. Clones or updates the template repo in a temporary directory
8
+ * 2. Copies all files EXCEPT those in ./src/content which contain specific content
9
+ * 3. Preserves important local configuration files
10
+ * 4. Creates backups of files that will be overwritten
11
+ *
12
+ * Usage: npm run sync:template [--dry-run] [--backup] [--force]
13
+ */
14
+
15
+ import { execSync } from 'child_process';
16
+ import fs from 'fs/promises';
17
+ import path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const APP_ROOT = path.resolve(__dirname, '..');
22
+ const PROJECT_ROOT = path.resolve(APP_ROOT, '..');
23
+ const TEMP_DIR = path.join(PROJECT_ROOT, '.temp-template-sync');
24
+ const TEMPLATE_REPO = 'https://huggingface.co/spaces/tfrere/research-article-template';
25
+
26
+ // Files and directories to PRESERVE (do not overwrite)
27
+ const PRESERVE_PATHS = [
28
+ // Project-specific content
29
+ 'app/src/content',
30
+
31
+ // Public data (symlink to our data) - CRITICAL: preserve this symlink
32
+ 'app/public/data',
33
+
34
+ // Local configuration
35
+ 'app/package-lock.json',
36
+ 'app/node_modules',
37
+
38
+ // Project-specific scripts (preserve our sync script)
39
+ 'app/scripts/sync-template.mjs',
40
+
41
+ // Project configuration files
42
+ 'README.md',
43
+ 'tools',
44
+
45
+ // Backup and temporary files
46
+ '.backup-*',
47
+ '.temp-*',
48
+
49
+ // Git
50
+ '.git',
51
+ '.gitignore'
52
+ ];
53
+
54
+ // Files to handle with caution (require confirmation)
55
+ const SENSITIVE_FILES = [
56
+ 'app/package.json',
57
+ 'app/astro.config.mjs',
58
+ 'Dockerfile',
59
+ 'nginx.conf'
60
+ ];
61
+
62
+ const args = process.argv.slice(2);
63
+ const isDryRun = args.includes('--dry-run');
64
+ const shouldBackup = args.includes('--backup'); // Disabled by default, use --backup to enable
65
+ const isForce = args.includes('--force');
66
+
67
+ console.log('🔄 Template synchronization script for research-article-template');
68
+ console.log(`📁 Working directory: ${PROJECT_ROOT}`);
69
+ console.log(`🎯 Template source: ${TEMPLATE_REPO}`);
70
+ if (isDryRun) console.log('🔍 DRY-RUN mode enabled - no files will be modified');
71
+ if (shouldBackup) console.log('💾 Backup enabled');
72
+ if (!shouldBackup) console.log('🚫 Backup disabled (use --backup to enable)');
73
+ console.log('');
74
+
75
+ async function executeCommand(command, options = {}) {
76
+ try {
77
+ if (isDryRun && !options.allowInDryRun) {
78
+ console.log(`[DRY-RUN] Command: ${command}`);
79
+ return '';
80
+ }
81
+ console.log(`$ ${command}`);
82
+ const result = execSync(command, {
83
+ encoding: 'utf8',
84
+ cwd: options.cwd || PROJECT_ROOT,
85
+ stdio: options.quiet ? 'pipe' : 'inherit'
86
+ });
87
+ return result;
88
+ } catch (error) {
89
+ console.error(`❌ Error during execution: ${command}`);
90
+ console.error(error.message);
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ async function pathExists(filePath) {
96
+ try {
97
+ await fs.access(filePath);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ async function isPathPreserved(relativePath) {
105
+ return PRESERVE_PATHS.some(preserve =>
106
+ relativePath === preserve ||
107
+ relativePath.startsWith(preserve + '/')
108
+ );
109
+ }
110
+
111
+ async function createBackup(filePath) {
112
+ if (!shouldBackup || isDryRun) return;
113
+
114
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
115
+ const backupPath = `${filePath}.backup-${timestamp}`;
116
+
117
+ try {
118
+ await fs.copyFile(filePath, backupPath);
119
+ console.log(`💾 Backup created: ${path.relative(PROJECT_ROOT, backupPath)}`);
120
+ } catch (error) {
121
+ console.warn(`⚠️ Unable to create backup for ${filePath}: ${error.message}`);
122
+ }
123
+ }
124
+
125
+ async function syncFile(sourcePath, targetPath) {
126
+ const relativeTarget = path.relative(PROJECT_ROOT, targetPath);
127
+
128
+ // Check if the file should be preserved
129
+ if (await isPathPreserved(relativeTarget)) {
130
+ console.log(`🔒 PRESERVED: ${relativeTarget}`);
131
+ return;
132
+ }
133
+
134
+ // Check if it's a sensitive file
135
+ if (SENSITIVE_FILES.includes(relativeTarget)) {
136
+ if (!isForce) {
137
+ console.log(`⚠️ SENSITIVE (ignored): ${relativeTarget} (use --force to overwrite)`);
138
+ return;
139
+ } else {
140
+ console.log(`⚠️ SENSITIVE (forced): ${relativeTarget}`);
141
+ }
142
+ }
143
+
144
+ // Check if target file is a symbolic link to preserve
145
+ if (await pathExists(targetPath)) {
146
+ try {
147
+ const targetStats = await fs.lstat(targetPath);
148
+ if (targetStats.isSymbolicLink()) {
149
+ console.log(`🔗 SYMLINK TARGET (preserved): ${relativeTarget}`);
150
+ return;
151
+ }
152
+ } catch (error) {
153
+ console.warn(`⚠️ Impossible de vérifier ${targetPath}: ${error.message}`);
154
+ }
155
+ }
156
+
157
+ // Create backup if file already exists (and is not a symbolic link)
158
+ if (await pathExists(targetPath)) {
159
+ try {
160
+ const stats = await fs.lstat(targetPath);
161
+ if (!stats.isSymbolicLink()) {
162
+ await createBackup(targetPath);
163
+ }
164
+ } catch (error) {
165
+ console.warn(`⚠️ Impossible de vérifier ${targetPath}: ${error.message}`);
166
+ }
167
+ }
168
+
169
+ if (isDryRun) {
170
+ console.log(`[DRY-RUN] COPY: ${relativeTarget}`);
171
+ return;
172
+ }
173
+
174
+ // Assurer que le répertoire parent existe
175
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
176
+
177
+ // Check if source is a symbolic link
178
+ try {
179
+ const sourceStats = await fs.lstat(sourcePath);
180
+ if (sourceStats.isSymbolicLink()) {
181
+ console.log(`🔗 SYMLINK SOURCE (ignored): ${relativeTarget}`);
182
+ return;
183
+ }
184
+ } catch (error) {
185
+ console.warn(`⚠️ Unable to check source ${sourcePath}: ${error.message}`);
186
+ return;
187
+ }
188
+
189
+ // Remove target file if it exists (to handle symbolic links)
190
+ if (await pathExists(targetPath)) {
191
+ await fs.rm(targetPath, { recursive: true, force: true });
192
+ }
193
+
194
+ // Copier le fichier
195
+ await fs.copyFile(sourcePath, targetPath);
196
+ console.log(`✅ COPIED: ${relativeTarget}`);
197
+ }
198
+
199
+ async function syncDirectory(sourceDir, targetDir) {
200
+ const items = await fs.readdir(sourceDir, { withFileTypes: true });
201
+
202
+ for (const item of items) {
203
+ const sourcePath = path.join(sourceDir, item.name);
204
+ const targetPath = path.join(targetDir, item.name);
205
+ const relativeTarget = path.relative(PROJECT_ROOT, targetPath);
206
+
207
+ if (await isPathPreserved(relativeTarget)) {
208
+ console.log(`🔒 DOSSIER PRÉSERVÉ: ${relativeTarget}/`);
209
+ continue;
210
+ }
211
+
212
+ if (item.isDirectory()) {
213
+ if (!isDryRun) {
214
+ await fs.mkdir(targetPath, { recursive: true });
215
+ }
216
+ await syncDirectory(sourcePath, targetPath);
217
+ } else {
218
+ await syncFile(sourcePath, targetPath);
219
+ }
220
+ }
221
+ }
222
+
223
+ async function cloneOrUpdateTemplate() {
224
+ console.log('📥 Fetching template...');
225
+
226
+ // Nettoyer le dossier temporaire s'il existe
227
+ if (await pathExists(TEMP_DIR)) {
228
+ await fs.rm(TEMP_DIR, { recursive: true, force: true });
229
+ if (isDryRun) {
230
+ console.log(`[DRY-RUN] Suppression: ${TEMP_DIR}`);
231
+ }
232
+ }
233
+
234
+ // Clone template repo (even in dry-run to be able to compare)
235
+ await executeCommand(`git clone ${TEMPLATE_REPO} "${TEMP_DIR}"`, { allowInDryRun: true });
236
+
237
+ return TEMP_DIR;
238
+ }
239
+
240
+ async function ensureDataSymlink() {
241
+ const dataSymlinkPath = path.join(APP_ROOT, 'public', 'data');
242
+ const dataSourcePath = path.join(APP_ROOT, 'src', 'content', 'assets', 'data');
243
+
244
+ // Check if symlink exists and is correct
245
+ if (await pathExists(dataSymlinkPath)) {
246
+ try {
247
+ const stats = await fs.lstat(dataSymlinkPath);
248
+ if (stats.isSymbolicLink()) {
249
+ const target = await fs.readlink(dataSymlinkPath);
250
+ const expectedTarget = path.relative(path.dirname(dataSymlinkPath), dataSourcePath);
251
+ if (target === expectedTarget) {
252
+ console.log('🔗 Data symlink is correct');
253
+ return;
254
+ } else {
255
+ console.log(`⚠️ Data symlink points to wrong target: ${target} (expected: ${expectedTarget})`);
256
+ }
257
+ } else {
258
+ console.log('⚠️ app/public/data exists but is not a symlink');
259
+ }
260
+ } catch (error) {
261
+ console.log(`⚠️ Error checking symlink: ${error.message}`);
262
+ }
263
+ }
264
+
265
+ // Recreate symlink
266
+ if (!isDryRun) {
267
+ if (await pathExists(dataSymlinkPath)) {
268
+ await fs.rm(dataSymlinkPath, { recursive: true, force: true });
269
+ }
270
+ await fs.symlink(path.relative(path.dirname(dataSymlinkPath), dataSourcePath), dataSymlinkPath);
271
+ console.log('✅ Data symlink recreated');
272
+ } else {
273
+ console.log('[DRY-RUN] Would recreate data symlink');
274
+ }
275
+ }
276
+
277
+ async function showSummary(templateDir) {
278
+ console.log('\n📊 SYNCHRONIZATION SUMMARY');
279
+ console.log('================================');
280
+
281
+ console.log('\n🔒 Preserved files/directories:');
282
+ for (const preserve of PRESERVE_PATHS) {
283
+ const fullPath = path.join(PROJECT_ROOT, preserve);
284
+ if (await pathExists(fullPath)) {
285
+ console.log(` ✓ ${preserve}`);
286
+ } else {
287
+ console.log(` - ${preserve} (n'existe pas)`);
288
+ }
289
+ }
290
+
291
+ console.log('\n⚠️ Sensitive files (require --force):');
292
+ for (const sensitive of SENSITIVE_FILES) {
293
+ const fullPath = path.join(PROJECT_ROOT, sensitive);
294
+ if (await pathExists(fullPath)) {
295
+ console.log(` ! ${sensitive}`);
296
+ }
297
+ }
298
+
299
+ if (isDryRun) {
300
+ console.log('\n🔍 To execute for real: npm run sync:template');
301
+ console.log('🔧 To force sensitive files: npm run sync:template -- --force');
302
+ }
303
+ }
304
+
305
+ async function cleanup() {
306
+ console.log('\n🧹 Cleaning up...');
307
+ if (await pathExists(TEMP_DIR)) {
308
+ if (!isDryRun) {
309
+ await fs.rm(TEMP_DIR, { recursive: true, force: true });
310
+ }
311
+ console.log(`🗑️ Temporary directory removed: ${TEMP_DIR}`);
312
+ }
313
+ }
314
+
315
+ async function main() {
316
+ try {
317
+ // Verify we're in the correct directory
318
+ const packageJsonPath = path.join(APP_ROOT, 'package.json');
319
+ if (!(await pathExists(packageJsonPath))) {
320
+ throw new Error(`Package.json not found in ${APP_ROOT}. Are you in the correct directory?`);
321
+ }
322
+
323
+ // Clone the template
324
+ const templateDir = await cloneOrUpdateTemplate();
325
+
326
+ // Synchroniser
327
+ console.log('\n🔄 Synchronisation en cours...');
328
+ await syncDirectory(templateDir, PROJECT_ROOT);
329
+
330
+ // S'assurer que le lien symbolique des données est correct
331
+ console.log('\n🔗 Vérification du lien symbolique des données...');
332
+ await ensureDataSymlink();
333
+
334
+ // Afficher le résumé
335
+ await showSummary(templateDir);
336
+
337
+ console.log('\n✅ Synchronization completed!');
338
+
339
+ } catch (error) {
340
+ console.error('\n❌ Error during synchronization:');
341
+ console.error(error.message);
342
+ process.exit(1);
343
+ } finally {
344
+ await cleanup();
345
+ }
346
+ }
347
+
348
+ // Signal handling to clean up on interruption
349
+ process.on('SIGINT', async () => {
350
+ console.log('\n\n⚠️ Interruption detected, cleaning up...');
351
+ await cleanup();
352
+ process.exit(1);
353
+ });
354
+
355
+ process.on('SIGTERM', async () => {
356
+ console.log('\n\n⚠️ Shutdown requested, cleaning up...');
357
+ await cleanup();
358
+ process.exit(1);
359
+ });
360
+
361
+ main();
app/src/components/Glossary.astro ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ interface Props {
3
+ /** The word or term to define */
4
+ term: string;
5
+ /** The definition of the term */
6
+ definition: string;
7
+ /** Optional CSS class to apply to the term */
8
+ class?: string;
9
+ /** Optional style to apply to the term */
10
+ style?: string;
11
+ /** Tooltip position (top, bottom, left, right) */
12
+ position?: "top" | "bottom" | "left" | "right";
13
+ /** Delay before showing tooltip in ms */
14
+ delay?: number;
15
+ /** Disable tooltip on mobile */
16
+ disableOnMobile?: boolean;
17
+ }
18
+
19
+ const {
20
+ term,
21
+ definition,
22
+ class: className = "",
23
+ style: inlineStyle = "",
24
+ position = "top",
25
+ delay = 300,
26
+ disableOnMobile = false,
27
+ } = Astro.props as Props;
28
+
29
+ // Generate a unique ID for this component
30
+ const tooltipId = `glossary-${Math.random().toString(36).slice(2)}`;
31
+ ---
32
+
33
+ <div class="glossary-container" data-glossary-container-id={tooltipId}>
34
+ <span
35
+ class={`glossary-term ${className}`}
36
+ style={inlineStyle}
37
+ data-glossary-term={term}
38
+ data-glossary-definition={definition}
39
+ data-glossary-position={position}
40
+ data-glossary-delay={delay}
41
+ data-glossary-disable-mobile={disableOnMobile}
42
+ data-glossary-id={tooltipId}
43
+ tabindex="0"
44
+ role="button"
45
+ aria-describedby={`${tooltipId}-tooltip`}
46
+ >
47
+ {term}
48
+ </span>
49
+
50
+ <div
51
+ id={`${tooltipId}-tooltip`}
52
+ class="glossary-tooltip"
53
+ data-glossary-tooltip-id={tooltipId}
54
+ data-position={position}
55
+ role="tooltip"
56
+ aria-hidden="true"
57
+ >
58
+ <div class="glossary-tooltip__content">
59
+ <div class="glossary-tooltip__term">{term}</div>
60
+ <div class="glossary-tooltip__definition">{definition}</div>
61
+ </div>
62
+ <div class="glossary-tooltip__arrow"></div>
63
+ </div>
64
+ </div>
65
+
66
+ <script is:inline>
67
+ // Global script for all Glossary tooltips
68
+ if (!window.glossaryInitialized) {
69
+ window.glossaryInitialized = true;
70
+
71
+ function initAllGlossaryTooltips() {
72
+ const glossaryTerms = document.querySelectorAll(".glossary-term");
73
+
74
+ glossaryTerms.forEach((termElement) => {
75
+ const tooltipElement =
76
+ termElement.parentElement.querySelector(".glossary-tooltip");
77
+
78
+ if (!tooltipElement) return;
79
+
80
+ const term = termElement.getAttribute("data-glossary-term");
81
+ const definition = termElement.getAttribute("data-glossary-definition");
82
+
83
+ if (!term || !definition) return;
84
+
85
+ // Fonction pour afficher le tooltip au niveau de la souris
86
+ const showTooltip = (event) => {
87
+ tooltipElement.style.display = "block";
88
+ tooltipElement.style.opacity = "1";
89
+ tooltipElement.style.position = "fixed";
90
+ tooltipElement.style.top = event.clientY + 10 + "px";
91
+ tooltipElement.style.left = event.clientX + 10 + "px";
92
+ tooltipElement.style.zIndex = "9999";
93
+ tooltipElement.style.pointerEvents = "none";
94
+ };
95
+
96
+ const hideTooltip = () => {
97
+ tooltipElement.style.display = "none";
98
+ tooltipElement.style.opacity = "0";
99
+ };
100
+
101
+ // Add events
102
+ termElement.addEventListener("mouseenter", showTooltip);
103
+ termElement.addEventListener("mouseleave", hideTooltip);
104
+ termElement.addEventListener("mousemove", showTooltip);
105
+ });
106
+ }
107
+
108
+ // Initialize when DOM is ready
109
+ if (document.readyState === "loading") {
110
+ document.addEventListener("DOMContentLoaded", initAllGlossaryTooltips);
111
+ } else {
112
+ initAllGlossaryTooltips();
113
+ }
114
+
115
+ // Observe DOM changes for new elements
116
+ if (window.MutationObserver) {
117
+ const observer = new MutationObserver((mutations) => {
118
+ mutations.forEach((mutation) => {
119
+ if (mutation.type === "childList") {
120
+ mutation.addedNodes.forEach((node) => {
121
+ if (
122
+ node.nodeType === 1 &&
123
+ node.querySelector &&
124
+ node.querySelector(".glossary-term")
125
+ ) {
126
+ initAllGlossaryTooltips();
127
+ }
128
+ });
129
+ }
130
+ });
131
+ });
132
+
133
+ observer.observe(document.body, {
134
+ childList: true,
135
+ subtree: true,
136
+ });
137
+ }
138
+ }
139
+ </script>
140
+
141
+ <style>
142
+ /* ============================================================================ */
143
+ /* Glossary Component */
144
+ /* ============================================================================ */
145
+
146
+ .glossary-container {
147
+ display: inline;
148
+ position: relative;
149
+ }
150
+
151
+ .glossary-term {
152
+ color: var(--primary-color) !important;
153
+ text-decoration: none !important;
154
+ background: color-mix(in srgb, var(--primary-color) 15%, transparent);
155
+ border-bottom: 1px dashed
156
+ color-mix(in srgb, var(--primary-color) 100%, transparent) !important;
157
+ cursor: help;
158
+ transition: all 0.2s ease;
159
+ border-radius: 3px;
160
+ margin: 0 2px;
161
+ padding: 4px 8px;
162
+ }
163
+
164
+ .glossary-term:hover,
165
+ .glossary-term:focus {
166
+ color: var(--primary-color-hover) !important;
167
+ text-decoration: none !important;
168
+ background: color-mix(in srgb, var(--primary-color) 20%, transparent);
169
+ outline: none;
170
+ }
171
+
172
+ .glossary-term:focus {
173
+ box-shadow: 0 0 0 2px
174
+ color-mix(in srgb, var(--primary-color) 20%, transparent);
175
+ }
176
+
177
+ .glossary-tooltip {
178
+ position: fixed;
179
+ top: -9999px;
180
+ left: -9999px;
181
+ z-index: var(--z-tooltip);
182
+ opacity: 0;
183
+ transform: translateY(-4px);
184
+ transition:
185
+ opacity 0.2s ease,
186
+ transform 0.2s ease;
187
+ pointer-events: none;
188
+ max-width: 300px;
189
+ min-width: 200px;
190
+ }
191
+
192
+ .glossary-tooltip.is-visible {
193
+ opacity: 1;
194
+ transform: translateY(0);
195
+ pointer-events: auto;
196
+ }
197
+
198
+ .glossary-tooltip__content {
199
+ background: var(--surface-bg);
200
+ border: 1px solid var(--border-color);
201
+ border-radius: 8px;
202
+ padding: 12px 16px;
203
+ box-shadow:
204
+ 0 8px 32px rgba(0, 0, 0, 0.12),
205
+ 0 2px 8px rgba(0, 0, 0, 0.06);
206
+ backdrop-filter: saturate(1.12) blur(8px);
207
+ }
208
+
209
+ .glossary-tooltip__term {
210
+ font-weight: 600;
211
+ font-size: 14px;
212
+ color: var(--primary-color);
213
+ margin-bottom: 4px;
214
+ line-height: 1.3;
215
+ }
216
+
217
+ .glossary-tooltip__definition {
218
+ font-size: 13px;
219
+ color: var(--text-color);
220
+ line-height: 1.4;
221
+ margin: 0;
222
+ }
223
+
224
+ .glossary-tooltip__arrow {
225
+ position: absolute;
226
+ width: 0;
227
+ height: 0;
228
+ border: 6px solid transparent;
229
+ }
230
+
231
+ /* Arrow positioning */
232
+ .glossary-tooltip[data-position="top"] .glossary-tooltip__arrow {
233
+ bottom: -6px;
234
+ left: 50%;
235
+ transform: translateX(-50%);
236
+ border-top-color: var(--border-color);
237
+ }
238
+
239
+ .glossary-tooltip[data-position="top"] .glossary-tooltip__arrow::after {
240
+ content: "";
241
+ position: absolute;
242
+ top: -7px;
243
+ left: -6px;
244
+ border: 6px solid transparent;
245
+ border-top-color: var(--surface-bg);
246
+ }
247
+
248
+ .glossary-tooltip[data-position="bottom"] .glossary-tooltip__arrow {
249
+ top: -6px;
250
+ left: 50%;
251
+ transform: translateX(-50%);
252
+ border-bottom-color: var(--border-color);
253
+ }
254
+
255
+ .glossary-tooltip[data-position="bottom"] .glossary-tooltip__arrow::after {
256
+ content: "";
257
+ position: absolute;
258
+ top: -5px;
259
+ left: -6px;
260
+ border: 6px solid transparent;
261
+ border-bottom-color: var(--surface-bg);
262
+ }
263
+
264
+ .glossary-tooltip[data-position="left"] .glossary-tooltip__arrow {
265
+ right: -6px;
266
+ top: 50%;
267
+ transform: translateY(-50%);
268
+ border-left-color: var(--border-color);
269
+ }
270
+
271
+ .glossary-tooltip[data-position="left"] .glossary-tooltip__arrow::after {
272
+ content: "";
273
+ position: absolute;
274
+ top: -6px;
275
+ left: -7px;
276
+ border: 6px solid transparent;
277
+ border-left-color: var(--surface-bg);
278
+ }
279
+
280
+ .glossary-tooltip[data-position="right"] .glossary-tooltip__arrow {
281
+ left: -6px;
282
+ top: 50%;
283
+ transform: translateY(-50%);
284
+ border-right-color: var(--border-color);
285
+ }
286
+
287
+ .glossary-tooltip[data-position="right"] .glossary-tooltip__arrow::after {
288
+ content: "";
289
+ position: absolute;
290
+ top: -6px;
291
+ left: -5px;
292
+ border: 6px solid transparent;
293
+ border-right-color: var(--surface-bg);
294
+ }
295
+
296
+ /* Mode sombre */
297
+ [data-theme="dark"] .glossary-tooltip__content {
298
+ background: var(--surface-bg);
299
+ border-color: var(--border-color);
300
+ }
301
+
302
+ [data-theme="dark"] .glossary-tooltip__term {
303
+ color: var(--primary-color);
304
+ }
305
+
306
+ [data-theme="dark"] .glossary-tooltip__definition {
307
+ color: var(--text-color);
308
+ }
309
+
310
+ /* Responsive - hide on mobile if disabled */
311
+ @media (max-width: 768px) {
312
+ .glossary-term[data-glossary-disable-mobile="true"] {
313
+ border-bottom: none;
314
+ color: inherit;
315
+ cursor: default;
316
+ }
317
+
318
+ .glossary-term[data-glossary-disable-mobile="true"]:hover,
319
+ .glossary-term[data-glossary-disable-mobile="true"]:focus {
320
+ background: none;
321
+ color: inherit;
322
+ border-bottom: none;
323
+ }
324
+ }
325
+
326
+ /* Accessibility improvement */
327
+ @media (prefers-reduced-motion: reduce) {
328
+ .glossary-tooltip {
329
+ transition: none;
330
+ }
331
+
332
+ .glossary-term {
333
+ transition: none;
334
+ }
335
+ }
336
+ </style>
app/src/components/Hero.astro CHANGED
@@ -12,6 +12,7 @@ interface Props {
12
  affiliation?: string; // legacy single affiliation
13
  published?: string;
14
  doi?: string;
 
15
  }
16
 
17
  const {
@@ -23,6 +24,7 @@ const {
23
  affiliation,
24
  published,
25
  doi,
 
26
  } = Astro.props as Props;
27
 
28
  type Author = { name: string; url?: string; affiliationIndices?: number[] };
@@ -111,12 +113,11 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
111
  shouldShowAffiliationSupers &&
112
  Array.isArray(a.affiliationIndices) &&
113
  a.affiliationIndices.length ? (
114
- <sup>{a.affiliationIndices.join(",")}</sup>
115
  ) : null;
116
  return (
117
  <li>
118
- {a.url ? <a href={a.url}>{a.name}</a> : a.name}
119
- {supers}
120
  </li>
121
  );
122
  })}
@@ -126,7 +127,7 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
126
  }
127
  {
128
  Array.isArray(affiliations) && affiliations.length > 0 && (
129
- <div class="meta-container-cell">
130
  <h3>Affiliation{affiliations.length > 1 ? "s" : ""}</h3>
131
  {hasMultipleAffiliations ? (
132
  <ol class="affiliations">
@@ -162,7 +163,7 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
162
  }
163
  {
164
  (!affiliations || affiliations.length === 0) && affiliation && (
165
- <div class="meta-container-cell">
166
  <h3>Affiliation</h3>
167
  <p>{affiliation}</p>
168
  </div>
@@ -183,26 +184,170 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
183
  </div>
184
  )} -->
185
  <div class="meta-container-cell meta-container-cell--pdf">
186
- <h3>PDF</h3>
187
- <p>
188
- <a
189
- class="button"
190
- href={`/${pdfFilename}`}
191
- download={pdfFilename}
192
- aria-label={`Download PDF ${pdfFilename}`}
193
- >
194
- Download PDF
195
- </a>
196
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  </div>
198
  </div>
199
  </header>
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  <style>
202
  /* Hero (full-width) */
203
  .hero {
204
  width: 100%;
205
- padding: 48px 16px 48px;
206
  text-align: center;
207
  }
208
  .hero-title {
@@ -259,6 +404,7 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
259
  display: flex;
260
  flex-direction: column;
261
  gap: 8px;
 
262
  }
263
  .meta-container-cell h3 {
264
  margin: 0;
@@ -275,6 +421,12 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
275
  margin: 0;
276
  list-style-type: none;
277
  padding-left: 0;
 
 
 
 
 
 
278
  }
279
  .affiliations {
280
  margin: 0;
@@ -289,9 +441,152 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
289
  row-gap: 12px;
290
  }
291
 
 
 
 
 
 
 
 
292
  @media print {
293
  .meta-container-cell--pdf {
294
  display: none !important;
295
  }
296
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  </style>
 
12
  affiliation?: string; // legacy single affiliation
13
  published?: string;
14
  doi?: string;
15
+ pdfProOnly?: boolean; // Gate PDF download to Pro users only
16
  }
17
 
18
  const {
 
24
  affiliation,
25
  published,
26
  doi,
27
+ pdfProOnly = false,
28
  } = Astro.props as Props;
29
 
30
  type Author = { name: string; url?: string; affiliationIndices?: number[] };
 
113
  shouldShowAffiliationSupers &&
114
  Array.isArray(a.affiliationIndices) &&
115
  a.affiliationIndices.length ? (
116
+ <sup>{a.affiliationIndices.join(", ")}</sup>
117
  ) : null;
118
  return (
119
  <li>
120
+ {a.url ? <a href={a.url}>{a.name}</a> : a.name}{supers}{i < normalizedAuthors.length - 1 && <span set:html=",&nbsp;" />}
 
121
  </li>
122
  );
123
  })}
 
127
  }
128
  {
129
  Array.isArray(affiliations) && affiliations.length > 0 && (
130
+ <div class="meta-container-cell meta-container-cell--affiliations">
131
  <h3>Affiliation{affiliations.length > 1 ? "s" : ""}</h3>
132
  {hasMultipleAffiliations ? (
133
  <ol class="affiliations">
 
163
  }
164
  {
165
  (!affiliations || affiliations.length === 0) && affiliation && (
166
+ <div class="meta-container-cell meta-container-cell--affiliations">
167
  <h3>Affiliation</h3>
168
  <p>{affiliation}</p>
169
  </div>
 
184
  </div>
185
  )} -->
186
  <div class="meta-container-cell meta-container-cell--pdf">
187
+ <div class="pdf-header-wrapper">
188
+ <h3>PDF</h3>
189
+ <span class="pro-badge-wrapper" style="display: none;">
190
+ <span class="pro-badge-prefix">- you are</span>
191
+ <span class="pro-badge">PRO</span>
192
+ </span>
193
+ <span class="pro-only-label" style="display: none;">
194
+ <span class="pro-only-dash">-</span>
195
+ <span class="pro-only-text">pro only</span>
196
+ <svg class="pro-only-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
197
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
198
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
199
+ </svg>
200
+ </span>
201
+ </div>
202
+ <div id="pdf-download-container" data-pdf-pro-only={pdfProOnly.toString()}>
203
+ <p class="pdf-loading">Checking access...</p>
204
+ <p class="pdf-pro-only" style="display: none;">
205
+ <a
206
+ class="button"
207
+ href={`/${pdfFilename}`}
208
+ download={pdfFilename}
209
+ aria-label={`Download PDF ${pdfFilename}`}
210
+ >
211
+ Download PDF
212
+ </a>
213
+ </p>
214
+ <div class="pdf-locked" style="display: none;">
215
+ <a
216
+ class="button button-locked"
217
+ href="https://huggingface.co/subscribe/pro"
218
+ target="_blank"
219
+ rel="noopener noreferrer"
220
+ >
221
+ <svg class="lock-icon" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" role="img" width="1em" height="1em" viewBox="0 0 12 12" fill="none">
222
+ <path d="M6.48 1.26c0 1.55.67 2.58 1.5 3.24.86.68 1.9 1 2.58 1.07v.86A5.3 5.3 0 0 0 7.99 7.5a3.95 3.95 0 0 0-1.51 3.24h-.96c0-1.55-.67-2.58-1.5-3.24a5.3 5.3 0 0 0-2.58-1.07v-.86A5.3 5.3 0 0 0 4.01 4.5a3.95 3.95 0 0 0 1.51-3.24h.96Z" fill="currentColor"></path>
223
+ </svg>
224
+ <span class="locked-title">Subscribe to Pro</span>
225
+ </a>
226
+ </div>
227
+ </div>
228
  </div>
229
  </div>
230
  </header>
231
 
232
+ <script>
233
+ // PDF access control for Pro users only
234
+
235
+ // ⚙️ Configuration for local development
236
+ const LOCAL_IS_PRO = false; // Set to true to test Pro access locally
237
+
238
+ const FALLBACK_TIMEOUT_MS = 3000;
239
+ let userPlanChecked = false;
240
+
241
+ // Check if PDF Pro gating is enabled
242
+ const pdfContainer = document.querySelector("#pdf-download-container") as HTMLElement;
243
+ const pdfProOnly = pdfContainer?.getAttribute("data-pdf-pro-only") === "true";
244
+
245
+ /**
246
+ * Check if user has Pro access
247
+ * Isolated logic for Pro user verification
248
+ * Expected plan structure: { user: "pro", org: "enterprise" }
249
+ */
250
+ function isProUser(plan: any): boolean {
251
+ if (!plan) return false;
252
+
253
+ // Check if user property is "pro"
254
+ return plan.user === "pro";
255
+ }
256
+
257
+ /**
258
+ * Update UI based on user's Pro status
259
+ */
260
+ function updatePdfAccess(isPro: boolean) {
261
+ const loadingEl = document.querySelector(".pdf-loading") as HTMLElement;
262
+ const proOnlyEl = document.querySelector(".pdf-pro-only") as HTMLElement;
263
+ const lockedEl = document.querySelector(".pdf-locked") as HTMLElement;
264
+ const proOnlyLabel = document.querySelector(".pro-only-label") as HTMLElement;
265
+ const proBadgeWrapper = document.querySelector(".pro-badge-wrapper") as HTMLElement;
266
+
267
+ // Hide loading state
268
+ if (loadingEl) loadingEl.style.display = "none";
269
+
270
+ // If PDF Pro gating is disabled, just show the download button
271
+ if (!pdfProOnly) {
272
+ if (proOnlyEl) proOnlyEl.style.display = "block";
273
+ if (proOnlyLabel) proOnlyLabel.style.display = "none";
274
+ if (lockedEl) lockedEl.style.display = "none";
275
+ if (proBadgeWrapper) proBadgeWrapper.style.display = "none";
276
+ return;
277
+ }
278
+
279
+ // Show appropriate state based on Pro status
280
+ if (isPro) {
281
+ if (proOnlyEl) proOnlyEl.style.display = "block";
282
+ if (proOnlyLabel) proOnlyLabel.style.display = "none";
283
+ if (lockedEl) lockedEl.style.display = "none";
284
+ if (proBadgeWrapper) proBadgeWrapper.style.display = "inline-flex";
285
+ } else {
286
+ if (proOnlyEl) proOnlyEl.style.display = "none";
287
+ if (proOnlyLabel) proOnlyLabel.style.display = "inline-flex";
288
+ if (lockedEl) lockedEl.style.display = "block";
289
+ if (proBadgeWrapper) proBadgeWrapper.style.display = "none";
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Handle user plan response
295
+ */
296
+ function handleUserPlan(plan: any) {
297
+ userPlanChecked = true;
298
+ const isPro = isProUser(plan);
299
+ updatePdfAccess(isPro);
300
+
301
+ // Optional: log for debugging
302
+ console.log("[PDF Access]", { plan, isPro });
303
+ }
304
+
305
+ /**
306
+ * Fallback behavior when no parent window responds
307
+ * Uses LOCAL_IS_PRO configuration for local development
308
+ */
309
+ function handleFallback() {
310
+ if (LOCAL_IS_PRO) {
311
+ handleUserPlan({ user: "pro" });
312
+ } else {
313
+ handleUserPlan({ user: "free" });
314
+ }
315
+ }
316
+
317
+ // If PDF Pro gating is disabled, show the download button immediately
318
+ if (!pdfProOnly) {
319
+ updatePdfAccess(true);
320
+ } else {
321
+ // Listen for messages from parent window (Hugging Face Spaces)
322
+ window.addEventListener("message", (event) => {
323
+ if (event.data.type === "USER_PLAN") {
324
+ handleUserPlan(event.data.plan);
325
+ }
326
+ });
327
+
328
+ // Request user plan on page load
329
+ if (window.parent && window.parent !== window) {
330
+ // We're in an iframe, request user plan
331
+ window.parent.postMessage({ type: "USER_PLAN_REQUEST" }, "*");
332
+
333
+ // Fallback if no response after timeout
334
+ setTimeout(() => {
335
+ if (!userPlanChecked) {
336
+ handleFallback();
337
+ }
338
+ }, FALLBACK_TIMEOUT_MS);
339
+ } else {
340
+ // Not in iframe (local development), use fallback immediately
341
+ handleFallback();
342
+ }
343
+ }
344
+ </script>
345
+
346
  <style>
347
  /* Hero (full-width) */
348
  .hero {
349
  width: 100%;
350
+ padding: 48px 16px 16px;
351
  text-align: center;
352
  }
353
  .hero-title {
 
404
  display: flex;
405
  flex-direction: column;
406
  gap: 8px;
407
+ max-width: 250px;
408
  }
409
  .meta-container-cell h3 {
410
  margin: 0;
 
421
  margin: 0;
422
  list-style-type: none;
423
  padding-left: 0;
424
+ display: flex;
425
+ flex-wrap: wrap;
426
+ }
427
+ .authors li {
428
+ white-space: nowrap;
429
+ padding:0;
430
  }
431
  .affiliations {
432
  margin: 0;
 
441
  row-gap: 12px;
442
  }
443
 
444
+ @media (max-width: 768px) {
445
+ .meta-container-cell--affiliations,
446
+ .meta-container-cell--pdf {
447
+ text-align: right;
448
+ }
449
+ }
450
+
451
  @media print {
452
  .meta-container-cell--pdf {
453
  display: none !important;
454
  }
455
  }
456
+
457
+ /* PDF access control styles */
458
+ .pdf-header-wrapper {
459
+ display: flex;
460
+ align-items: center;
461
+ gap: 6px;
462
+ line-height: 1;
463
+ }
464
+
465
+ .pdf-header-wrapper h3 {
466
+ line-height: 1;
467
+ }
468
+
469
+ .pdf-loading {
470
+ color: var(--muted-color);
471
+ font-size: 0.9em;
472
+ }
473
+
474
+ .pdf-pro-only {
475
+ margin: 0;
476
+ }
477
+
478
+ .pro-badge-wrapper {
479
+ display: inline-flex;
480
+ align-items: center;
481
+ gap: 5px;
482
+ font-style: normal;
483
+ }
484
+
485
+ .pro-badge-prefix {
486
+ font-size: 0.85em;
487
+ opacity: 0.5;
488
+ font-weight: 400;
489
+ font-style: normal;
490
+ }
491
+
492
+ .pro-badge {
493
+ display: inline-block;
494
+ border: 1px solid rgba(0, 0, 0, 0.025);
495
+ background: linear-gradient(to bottom right, #f9a8d4, #86efac, #fde047);
496
+ color: black;
497
+ padding: 1px 5px;
498
+ border-radius: 3px;
499
+ font-size: 0.5rem;
500
+ font-weight: 700;
501
+ font-style: normal;
502
+ letter-spacing: 0.025em;
503
+ text-transform: uppercase;
504
+ }
505
+
506
+ /* Dark mode pro badge */
507
+ :global(.dark) .pro-badge,
508
+ :global([data-theme="dark"]) .pro-badge {
509
+ background: linear-gradient(to bottom right, #ec4899, #22c55e, #eab308);
510
+ border-color: rgba(255, 255, 255, 0.15);
511
+ }
512
+
513
+ .pro-only-label {
514
+ display: inline-flex;
515
+ flex-direction: row;
516
+ align-items: center;
517
+ gap: 5px;
518
+ font-size: 0.85em;
519
+ opacity: 0.5;
520
+ font-weight: 400;
521
+ line-height: 1;
522
+ }
523
+
524
+ .pro-only-dash {
525
+ display: inline-flex;
526
+ align-items: center;
527
+ line-height: 1;
528
+ }
529
+
530
+ .pro-only-icon {
531
+ width: 11px;
532
+ height: 11px;
533
+ flex-shrink: 0;
534
+ display: inline-flex;
535
+ align-items: center;
536
+ }
537
+
538
+ .pro-only-text {
539
+ display: inline-flex;
540
+ align-items: center;
541
+ line-height: 1;
542
+ }
543
+
544
+ .pdf-locked {
545
+ display: block;
546
+ }
547
+
548
+ .button-locked {
549
+ display: inline-flex;
550
+ align-items: center;
551
+ gap: 6px;
552
+ background: linear-gradient(135deg,
553
+ var(--primary-color) 0%,
554
+ oklch(from var(--primary-color) calc(l - 0.1) calc(c + 0.05) calc(h - 60)) 100%);
555
+ border-radius: var(--button-radius);
556
+ padding: var(--button-padding-y) var(--button-padding-x);
557
+ font-size: var(--button-font-size);
558
+ line-height: 1;
559
+ color: var(--on-primary);
560
+ position: relative;
561
+ overflow: hidden;
562
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
563
+ font-weight: normal;
564
+ border-color: rgba(0, 0, 0, 0.15);
565
+ }
566
+
567
+ .button-locked:active {
568
+ transform: translateY(0);
569
+ }
570
+
571
+ .lock-icon {
572
+ font-size: 1em;
573
+ flex-shrink: 0;
574
+ position: relative;
575
+ z-index: 1;
576
+ }
577
+
578
+ .locked-title {
579
+ position: relative;
580
+ z-index: 1;
581
+ }
582
+
583
+ /* Dark mode locked button - inherits from light mode variables */
584
+
585
+ @media (max-width: 768px) {
586
+ .meta-container-cell--pdf {
587
+ display: flex;
588
+ flex-direction: column;
589
+ align-items: flex-end;
590
+ }
591
+ }
592
  </style>
app/src/components/HtmlEmbed.astro CHANGED
@@ -20,12 +20,15 @@ const html = resolveFragment(src);
20
  const mountId = `frag-${Math.random().toString(36).slice(2)}`;
21
  const dataAttr = Array.isArray(data) ? JSON.stringify(data) : (typeof data === 'string' ? data : undefined);
22
  const configAttr = typeof config === 'string' ? config : (config != null ? JSON.stringify(config) : undefined);
 
 
 
23
  ---
24
  { html ? (
25
  <figure class="html-embed" id={id}>
26
  {title && <figcaption class="html-embed__title" style={`text-align:${align}`}>{title}</figcaption>}
27
  <div class={`html-embed__card${frameless ? ' is-frameless' : ''}`}>
28
- <div id={mountId} data-datafiles={dataAttr} data-config={configAttr} set:html={html} />
29
  </div>
30
  {desc && <figcaption class="html-embed__desc" style={`text-align:${align}`} set:html={desc}></figcaption>}
31
  </figure>
@@ -70,7 +73,6 @@ const configAttr = typeof config === 'string' ? config : (config != null ? JSON.
70
  .html-embed { margin: 0 0 var(--block-spacing-y);
71
  z-index: var(--z-elevated);
72
  position: relative;
73
-
74
  }
75
  .html-embed__title {
76
  text-align: left;
@@ -83,12 +85,14 @@ const configAttr = typeof config === 'string' ? config : (config != null ? JSON.
83
  position: relative;
84
  display: block;
85
  width: 100%;
 
 
86
  }
87
  .html-embed__card {
88
  background: var(--code-bg);
89
  border: 1px solid var(--border-color);
90
  border-radius: 10px;
91
- padding: 8px;
92
  z-index: calc(var(--z-elevated) + 1);
93
  position: relative;
94
  }
@@ -108,6 +112,7 @@ const configAttr = typeof config === 'string' ? config : (config != null ? JSON.
108
  z-index: var(--z-elevated);
109
  display: block;
110
  width: 100%;
 
111
  }
112
  /* Plotly – fragments & controls */
113
  .html-embed__card svg text { fill: var(--text-color); }
 
20
  const mountId = `frag-${Math.random().toString(36).slice(2)}`;
21
  const dataAttr = Array.isArray(data) ? JSON.stringify(data) : (typeof data === 'string' ? data : undefined);
22
  const configAttr = typeof config === 'string' ? config : (config != null ? JSON.stringify(config) : undefined);
23
+
24
+ // Apply the ID to the HTML content if provided
25
+ const htmlWithId = id && html ? html.replace(/<div class="([^"]*)"[^>]*>/, `<div class="$1" id="${id}">`) : html;
26
  ---
27
  { html ? (
28
  <figure class="html-embed" id={id}>
29
  {title && <figcaption class="html-embed__title" style={`text-align:${align}`}>{title}</figcaption>}
30
  <div class={`html-embed__card${frameless ? ' is-frameless' : ''}`}>
31
+ <div id={mountId} data-datafiles={dataAttr} data-config={configAttr} set:html={htmlWithId} />
32
  </div>
33
  {desc && <figcaption class="html-embed__desc" style={`text-align:${align}`} set:html={desc}></figcaption>}
34
  </figure>
 
73
  .html-embed { margin: 0 0 var(--block-spacing-y);
74
  z-index: var(--z-elevated);
75
  position: relative;
 
76
  }
77
  .html-embed__title {
78
  text-align: left;
 
85
  position: relative;
86
  display: block;
87
  width: 100%;
88
+ background: var(--page-bg);
89
+ z-index: var(--z-elevated);
90
  }
91
  .html-embed__card {
92
  background: var(--code-bg);
93
  border: 1px solid var(--border-color);
94
  border-radius: 10px;
95
+ padding: 24px;
96
  z-index: calc(var(--z-elevated) + 1);
97
  position: relative;
98
  }
 
112
  z-index: var(--z-elevated);
113
  display: block;
114
  width: 100%;
115
+ background: var(--page-bg);
116
  }
117
  /* Plotly – fragments & controls */
118
  .html-embed__card svg text { fill: var(--text-color); }
app/src/components/Image.astro ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ // @ts-ignore - types provided by Astro at runtime
3
+ import { Image as AstroImage } from "astro:assets";
4
+
5
+ interface Props {
6
+ /** Source image imported via astro:assets */
7
+ src: any;
8
+ /** Alt text for accessibility */
9
+ alt: string;
10
+ /** Optional HTML string caption (use slot caption for rich content) */
11
+ caption?: string;
12
+ /** Optional class to apply on the <figure> wrapper when caption is used */
13
+ figureClass?: string;
14
+ /** Enable medium-zoom behavior on this image */
15
+ zoomable?: boolean;
16
+ /** Show a download button overlay and enable download flow */
17
+ downloadable?: boolean;
18
+ /** Optional explicit file name to use on download */
19
+ downloadName?: string;
20
+ /** Optional explicit source URL to download instead of currentSrc */
21
+ downloadSrc?: string;
22
+ /** Optional link that wraps the image (not the caption) */
23
+ linkHref?: string;
24
+ /** Optional target for the link (default: _blank when linkHref provided) */
25
+ linkTarget?: string;
26
+ /** Optional rel for the link (default: noopener noreferrer when linkHref provided) */
27
+ linkRel?: string;
28
+ /** Make the image span full width */
29
+ fullWidth?: boolean;
30
+ /** Any additional attributes should be forwarded to the underlying <AstroImage> */
31
+ [key: string]: any;
32
+ }
33
+
34
+ const {
35
+ caption,
36
+ figureClass,
37
+ zoomable,
38
+ downloadable,
39
+ downloadName,
40
+ downloadSrc,
41
+ linkHref,
42
+ linkTarget,
43
+ linkRel,
44
+ fullWidth,
45
+ ...imgProps
46
+ } = Astro.props as Props;
47
+ const hasCaptionSlot = Astro.slots.has("caption");
48
+ const hasCaption =
49
+ hasCaptionSlot || (typeof caption === "string" && caption.length > 0);
50
+ const hasTitle = Astro.slots.has("title");
51
+ const uid = `ri_${Math.random().toString(36).slice(2)}`;
52
+ const dataZoomable =
53
+ zoomable === true || (imgProps as any)["data-zoomable"] ? "1" : undefined;
54
+ const dataDownloadable =
55
+ downloadable === true || (imgProps as any)["data-downloadable"]
56
+ ? "1"
57
+ : undefined;
58
+ const hasLink = typeof linkHref === "string" && linkHref.length > 0;
59
+ const resolvedTarget = hasLink ? linkTarget || "_blank" : undefined;
60
+ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
61
+ ---
62
+
63
+ <div
64
+ class={`ri-root`}
65
+ data-ri-root={uid}
66
+ data-has-title={hasTitle}
67
+ data-has-caption={hasCaption}
68
+ >
69
+ {
70
+ hasCaption ? (
71
+ <figure
72
+ class={(figureClass || "") + (dataDownloadable ? " has-dl-btn" : "")}
73
+ >
74
+ {dataDownloadable ? (
75
+ <span class="img-dl-wrap">
76
+ {hasLink ? (
77
+ <a
78
+ class="ri-link"
79
+ href={linkHref}
80
+ target={resolvedTarget}
81
+ rel={resolvedRel}
82
+ >
83
+ <AstroImage
84
+ {...imgProps}
85
+ data-zoomable={dataZoomable}
86
+ data-downloadable={dataDownloadable}
87
+ data-download-name={downloadName}
88
+ data-download-src={downloadSrc}
89
+ />
90
+ </a>
91
+ ) : (
92
+ <AstroImage
93
+ {...imgProps}
94
+ data-zoomable={dataZoomable}
95
+ data-downloadable={dataDownloadable}
96
+ data-download-name={downloadName}
97
+ data-download-src={downloadSrc}
98
+ />
99
+ )}
100
+ <button
101
+ type="button"
102
+ class="button img-dl-btn"
103
+ aria-label="Download image"
104
+ title={
105
+ downloadName ? `Download ${downloadName}` : "Download image"
106
+ }
107
+ >
108
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
109
+ <path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z" />
110
+ </svg>
111
+ </button>
112
+ </span>
113
+ ) : hasLink ? (
114
+ <a
115
+ class="ri-link"
116
+ href={linkHref}
117
+ target={resolvedTarget}
118
+ rel={resolvedRel}
119
+ >
120
+ <AstroImage {...imgProps} data-zoomable={dataZoomable} />
121
+ </a>
122
+ ) : (
123
+ <AstroImage {...imgProps} data-zoomable={dataZoomable} />
124
+ )}
125
+ <figcaption>
126
+ {hasCaptionSlot ? (
127
+ <slot name="caption" />
128
+ ) : (
129
+ caption && <span set:html={caption} />
130
+ )}
131
+ </figcaption>
132
+ </figure>
133
+ ) : dataDownloadable ? (
134
+ <span class="img-dl-wrap">
135
+ {hasLink ? (
136
+ <a
137
+ class="ri-link"
138
+ href={linkHref}
139
+ target={resolvedTarget}
140
+ rel={resolvedRel}
141
+ >
142
+ <AstroImage
143
+ {...imgProps}
144
+ data-zoomable={dataZoomable}
145
+ data-downloadable={dataDownloadable}
146
+ data-download-name={downloadName}
147
+ data-download-src={downloadSrc}
148
+ />
149
+ </a>
150
+ ) : (
151
+ <AstroImage
152
+ {...imgProps}
153
+ data-zoomable={dataZoomable}
154
+ data-downloadable={dataDownloadable}
155
+ data-download-name={downloadName}
156
+ data-download-src={downloadSrc}
157
+ />
158
+ )}
159
+ <button
160
+ type="button"
161
+ class="button img-dl-btn"
162
+ aria-label="Download image"
163
+ title={downloadName ? `Download ${downloadName}` : "Download image"}
164
+ >
165
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
166
+ <path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z" />
167
+ </svg>
168
+ </button>
169
+ </span>
170
+ ) : hasLink ? (
171
+ <a
172
+ class="ri-link"
173
+ href={linkHref}
174
+ target={resolvedTarget}
175
+ rel={resolvedRel}
176
+ >
177
+ <AstroImage
178
+ {...imgProps}
179
+ data-zoomable={dataZoomable}
180
+ class={fullWidth ? "full" : ""}
181
+ />
182
+ </a>
183
+ ) : (
184
+ <AstroImage
185
+ {...imgProps}
186
+ data-zoomable={dataZoomable}
187
+ class={fullWidth ? "full" : ""}
188
+ />
189
+ )
190
+ }
191
+ </div>
192
+
193
+ <script is:inline>
194
+ (() => {
195
+ const scriptEl = document.currentScript;
196
+ const root = scriptEl ? scriptEl.previousElementSibling : null;
197
+ if (!root) {
198
+ console.log("Figure script: No root element found, exiting");
199
+ return;
200
+ }
201
+ const img =
202
+ root.tagName === "IMG"
203
+ ? root
204
+ : root.querySelector
205
+ ? root.querySelector("img")
206
+ : null;
207
+ if (!img) {
208
+ console.log("Figure script: No img element found, exiting");
209
+ return;
210
+ }
211
+
212
+ // medium-zoom integration scoped to this image only
213
+ const ensureMediumZoomReady = (cb) => {
214
+ // @ts-ignore
215
+ if (window.mediumZoom) return cb();
216
+ const retry = () => {
217
+ // @ts-ignore
218
+ if (window.mediumZoom) cb();
219
+ else setTimeout(retry, 30);
220
+ };
221
+ retry();
222
+ };
223
+
224
+ const initZoomIfNeeded = () => {
225
+ if (img.getAttribute("data-zoomable") !== "1") return;
226
+ const isDark =
227
+ document.documentElement.getAttribute("data-theme") === "dark";
228
+ const background = isDark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)";
229
+ ensureMediumZoomReady(() => {
230
+ // @ts-ignore
231
+ const instance = window.mediumZoom
232
+ ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 })
233
+ : null;
234
+ if (!instance) return;
235
+ let onScrollLike;
236
+ const attachCloseOnScroll = () => {
237
+ if (onScrollLike) return;
238
+ onScrollLike = () => {
239
+ try {
240
+ instance.close && instance.close();
241
+ } catch {}
242
+ };
243
+ window.addEventListener("wheel", onScrollLike, { passive: true });
244
+ window.addEventListener("touchmove", onScrollLike, { passive: true });
245
+ window.addEventListener("scroll", onScrollLike, { passive: true });
246
+ };
247
+ const detachCloseOnScroll = () => {
248
+ if (!onScrollLike) return;
249
+ window.removeEventListener("wheel", onScrollLike);
250
+ window.removeEventListener("touchmove", onScrollLike);
251
+ window.removeEventListener("scroll", onScrollLike);
252
+ onScrollLike = null;
253
+ };
254
+ try {
255
+ instance.on && instance.on("open", attachCloseOnScroll);
256
+ } catch {}
257
+ try {
258
+ instance.on && instance.on("close", detachCloseOnScroll);
259
+ } catch {}
260
+ const themeObserver = new MutationObserver(() => {
261
+ const dark =
262
+ document.documentElement.getAttribute("data-theme") === "dark";
263
+ try {
264
+ instance.update &&
265
+ instance.update({
266
+ background: dark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)",
267
+ });
268
+ } catch {}
269
+ });
270
+ themeObserver.observe(document.documentElement, {
271
+ attributes: true,
272
+ attributeFilter: ["data-theme"],
273
+ });
274
+ });
275
+ };
276
+
277
+ // Global zoom management to hide other Figures
278
+ const setupGlobalZoomBehavior = () => {
279
+ img.addEventListener("click", () => {
280
+ if (img.getAttribute("data-zoomable") === "1") {
281
+ // Enlever zoom-active de tous les autres ri-root
282
+ document
283
+ .querySelectorAll(".ri-root.zoom-active")
284
+ .forEach((el) => el.classList.remove("zoom-active"));
285
+
286
+ // Add zoom-active to this ri-root
287
+ root.classList.add("zoom-active");
288
+ }
289
+ });
290
+ };
291
+
292
+ // Download button handler
293
+ const dlBtn = root.querySelector ? root.querySelector(".img-dl-btn") : null;
294
+ if (dlBtn) {
295
+ dlBtn.addEventListener("click", async (ev) => {
296
+ try {
297
+ ev.preventDefault();
298
+ ev.stopPropagation();
299
+ const pickHrefAndName = () => {
300
+ const current = img.currentSrc || img.src || "";
301
+ let href = img.getAttribute("data-download-src") || current;
302
+ const deriveName = () => {
303
+ try {
304
+ const u = new URL(current, location.href);
305
+ const rawHref = u.searchParams.get("href");
306
+ const candidate = rawHref
307
+ ? decodeURIComponent(rawHref)
308
+ : u.pathname;
309
+ const last = String(candidate).split("/").pop() || "";
310
+ const base = last.split("?")[0].split("#")[0];
311
+ const m = base.match(
312
+ /^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i,
313
+ );
314
+ if (m && m[1]) return m[1];
315
+ return base || "image";
316
+ } catch {
317
+ return "image";
318
+ }
319
+ };
320
+ const name = img.getAttribute("data-download-name") || deriveName();
321
+ return { href, name };
322
+ };
323
+ const picked = pickHrefAndName();
324
+ const res = await fetch(picked.href, { credentials: "same-origin" });
325
+ const blob = await res.blob();
326
+ const objectUrl = URL.createObjectURL(blob);
327
+ const tmp = document.createElement("a");
328
+ tmp.href = objectUrl;
329
+ tmp.download = picked.name || "image";
330
+ tmp.target = "_self";
331
+ tmp.rel = "noopener";
332
+ tmp.style.display = "none";
333
+ document.body.appendChild(tmp);
334
+ tmp.click();
335
+ setTimeout(() => {
336
+ URL.revokeObjectURL(objectUrl);
337
+ tmp.remove();
338
+ }, 1000);
339
+ } catch {}
340
+ });
341
+ }
342
+
343
+ // Setup comportement zoom
344
+ setupGlobalZoomBehavior();
345
+
346
+ if (document.readyState === "complete") initZoomIfNeeded();
347
+ else window.addEventListener("load", initZoomIfNeeded, { once: true });
348
+ })();
349
+ </script>
350
+
351
+ <style>
352
+ figure {
353
+ margin: var(--block-spacing-y) 0;
354
+ }
355
+ figcaption {
356
+ text-align: left;
357
+ font-size: 0.9rem;
358
+ color: var(--muted-color);
359
+ margin-top: 6px;
360
+ }
361
+ figcaption {
362
+ background: var(--page-bg);
363
+ position: relative;
364
+ z-index: var(--z-elevated);
365
+ display: block;
366
+ width: 100%;
367
+ }
368
+ .image-credit {
369
+ display: block;
370
+ margin-top: 4px;
371
+ font-size: 12px;
372
+ color: var(--muted-color);
373
+ }
374
+ .image-credit a {
375
+ color: inherit;
376
+ text-decoration: underline;
377
+ text-underline-offset: 2px;
378
+ }
379
+
380
+ /* Zoomable overlay container (if used by any lightbox implementation) */
381
+ [data-zoom-overlay],
382
+ .zoom-overlay {
383
+ position: fixed;
384
+ inset: 0;
385
+ z-index: var(--z-overlay);
386
+ }
387
+
388
+ /* Download link inside figures */
389
+ figure .download-link {
390
+ position: relative;
391
+ z-index: var(--z-elevated);
392
+ }
393
+
394
+ /* Opt-in zoomable images */
395
+ img[data-zoomable] {
396
+ cursor: zoom-in;
397
+ }
398
+ .medium-zoom--opened img[data-zoomable] {
399
+ cursor: zoom-out;
400
+ }
401
+
402
+ /* Download button for img[data-downloadable] */
403
+ figure.has-dl-btn {
404
+ position: relative;
405
+ }
406
+ .dl-host {
407
+ position: relative;
408
+ }
409
+ .img-dl-wrap {
410
+ position: relative;
411
+ display: inline-block;
412
+ }
413
+ .img-dl-btn {
414
+ position: absolute;
415
+ right: 8px;
416
+ bottom: 8px;
417
+ align-items: center;
418
+ justify-content: center;
419
+ width: 30px;
420
+ height: 30px;
421
+ border-radius: 6px;
422
+ color: white;
423
+ text-decoration: none;
424
+ border: 1px solid rgba(255, 255, 255, 0.25);
425
+ z-index: var(--z-elevated);
426
+ display: none;
427
+ background: var(--primary-color);
428
+ }
429
+
430
+ /* When an image is zoomed, hide ALL Figures on the page */
431
+ :global(.medium-zoom--opened) .ri-root {
432
+ opacity: 0;
433
+ z-index: calc(var(--z-base) - 1);
434
+ transition: opacity 0.3s ease;
435
+ }
436
+
437
+ /* The currently zoomed image remains visible */
438
+ :global(.medium-zoom--opened) .ri-root:has(.medium-zoom--opened) {
439
+ opacity: 1;
440
+ z-index: var(--z-overlay);
441
+ }
442
+
443
+ /* Fallback for browsers without :has() support */
444
+ :global(.medium-zoom--opened) .ri-root.zoom-active {
445
+ opacity: 1 !important;
446
+ z-index: var(--z-overlay) !important;
447
+ }
448
+
449
+ /* Specifically hide download button and figcaption during zoom */
450
+ :global(.medium-zoom--opened) .img-dl-btn {
451
+ opacity: 0;
452
+ z-index: calc(var(--z-base) - 1);
453
+ transition: opacity 0.3s ease;
454
+ }
455
+
456
+ :global(.medium-zoom--opened) figcaption {
457
+ opacity: 0;
458
+ z-index: calc(var(--z-base) - 1);
459
+ transition: opacity 0.3s ease;
460
+ }
461
+
462
+ /* Even for active zoomed image, hide button and caption for clean experience */
463
+ :global(.medium-zoom--opened) .ri-root.zoom-active .img-dl-btn {
464
+ opacity: 0;
465
+ z-index: calc(var(--z-base) - 1);
466
+ }
467
+
468
+ :global(.medium-zoom--opened) .ri-root.zoom-active figcaption {
469
+ opacity: 0;
470
+ z-index: calc(var(--z-base) - 1);
471
+ }
472
+ .img-dl-btn svg {
473
+ width: 18px;
474
+ height: 18px;
475
+ fill: currentColor;
476
+ }
477
+ .img-dl-wrap:hover .img-dl-btn {
478
+ display: inline-flex;
479
+ }
480
+ .img-dl-btn:hover {
481
+ background: var(--primary-color-hover);
482
+ }
483
+
484
+ [data-theme="dark"] .img-dl-btn {
485
+ background: var(--primary-color);
486
+ color: var(--on-primary);
487
+ border-color: var(--primary-color);
488
+ }
489
+ [data-theme="dark"] .img-dl-btn:hover {
490
+ background: var(--primary-color-hover);
491
+ }
492
+
493
+ /* Conditional margins based on title and caption presence */
494
+ .ri-root:not([data-has-title="true"]) {
495
+ margin-top: 20px;
496
+ }
497
+
498
+ .ri-root:not([data-has-caption="true"]) {
499
+ margin-bottom: 20px;
500
+ }
501
+
502
+ /* full image styles */
503
+ img.full {
504
+ width: 100% !important;
505
+ min-width: 100%;
506
+ max-width: 100%;
507
+ }
508
+ </style>
app/src/components/Quote.astro ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ interface Props {
3
+ source?: string;
4
+ }
5
+
6
+ const { source } = Astro.props;
7
+ ---
8
+
9
+ <blockquote class="quote">
10
+ <div class="quote__text">
11
+ <slot />
12
+ </div>
13
+ {
14
+ source && (
15
+ <footer class="quote__footer">
16
+ <span class="quote__source" set:html={source} />
17
+ </footer>
18
+ )
19
+ }
20
+ </blockquote>
21
+
22
+ <style>
23
+ .quote {
24
+ font-family: var(--default-font-family);
25
+ margin: 32px 0;
26
+ max-width: 600px;
27
+ text-align: left;
28
+ position: relative;
29
+ padding: 0;
30
+ border: none;
31
+ background: none;
32
+ box-shadow: none;
33
+ white-space: normal;
34
+ }
35
+
36
+ .quote::before {
37
+ content: '"';
38
+ position: absolute;
39
+ top: -24px;
40
+ left: -30px;
41
+ font-size: 8rem;
42
+ font-family: var(--default-font-family);
43
+ font-weight: 400;
44
+ color: var(--text-color);
45
+ opacity: 0.05;
46
+ z-index: -1;
47
+ line-height: 1;
48
+ pointer-events: none;
49
+ }
50
+
51
+ .quote__text {
52
+ font-size: 1.5rem;
53
+ line-height: 1.4;
54
+ font-style: normal;
55
+ font-weight: 400;
56
+ font-family:
57
+ "SF Pro Text",
58
+ -apple-system,
59
+ BlinkMacSystemFont,
60
+ "Segoe UI",
61
+ sans-serif;
62
+ color: var(--text-color);
63
+ margin-bottom: 12px;
64
+ padding: 0;
65
+ letter-spacing: -0.01em;
66
+ }
67
+
68
+ .quote__text p {
69
+ margin: 0;
70
+ }
71
+
72
+ .quote__footer {
73
+ font-size: 0.875rem;
74
+ color: var(--muted-color);
75
+ display: flex;
76
+ justify-content: flex-start;
77
+ align-items: center;
78
+ gap: 6px;
79
+ margin-top: 0;
80
+ font-family:
81
+ "SF Pro Text",
82
+ -apple-system,
83
+ BlinkMacSystemFont,
84
+ "Segoe UI",
85
+ sans-serif;
86
+ }
87
+
88
+ .quote__source {
89
+ font-weight: 500;
90
+ opacity: 0.85;
91
+ font-style: italic;
92
+ color: var(--text-color);
93
+ }
94
+
95
+ .quote__source::before {
96
+ content: "— ";
97
+ font-style: normal;
98
+ }
99
+
100
+ @media (max-width: 640px) {
101
+ .quote {
102
+ margin: 24px 0;
103
+ max-width: 100%;
104
+ }
105
+
106
+ .quote::before {
107
+ font-size: 6rem;
108
+ top: -18px;
109
+ left: -16px;
110
+ }
111
+
112
+ .quote__text {
113
+ font-size: 1.25rem;
114
+ line-height: 1.45;
115
+ padding: 0;
116
+ }
117
+
118
+ .quote__footer {
119
+ flex-direction: row;
120
+ gap: 6px;
121
+ font-size: 0.8rem;
122
+ }
123
+ }
124
+ </style>
app/src/components/Reference.astro ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ interface Props {
3
+ /** ID unique pour la référence */
4
+ id: string;
5
+ /** Légende HTML pour la référence */
6
+ caption: string;
7
+ }
8
+
9
+ const { id, caption } = Astro.props as Props;
10
+ ---
11
+
12
+ <div class="reference-wrapper" id={id}>
13
+ <figure class="reference">
14
+ <div class="reference__content">
15
+ <slot />
16
+ </div>
17
+ <figcaption class="reference__caption" set:html={caption} />
18
+ </figure>
19
+ </div>
20
+
21
+ <style>
22
+ .reference-wrapper {
23
+ margin: var(--block-spacing-y) 0;
24
+ }
25
+
26
+ .reference {
27
+ margin: 0;
28
+ }
29
+
30
+ .reference__content {
31
+ /* Le contenu peut être n'importe quoi */
32
+ }
33
+
34
+ .reference__caption {
35
+ text-align: left;
36
+ font-size: 0.9rem;
37
+ color: var(--muted-color);
38
+ margin-top: 6px;
39
+ background: var(--page-bg);
40
+ position: relative;
41
+ z-index: var(--z-elevated);
42
+ display: block;
43
+ width: 100%;
44
+ }
45
+ </style>
app/src/components/Sidenote.astro CHANGED
@@ -1,37 +1,99 @@
1
  ---
 
2
  ---
3
- <div class="aside">
4
- <div class="aside__main">
 
5
  <slot />
6
- </div>
7
- <aside class="aside__aside">
8
- <slot name="aside" />
9
  </aside>
10
  </div>
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  <style is:global>
14
- .aside {
 
15
  position: relative;
16
- margin: 12px 0;
 
 
 
 
 
 
 
 
17
  }
18
 
19
- .aside__aside {
20
- position: absolute;
21
- top: 0;
22
- right: -260px; /* push into the right grid column (width 260 + gap 32) */
23
- width: 260px;
24
  border-radius: 8px;
25
  padding: 0 30px;
26
  font-size: 0.9rem;
27
  color: var(--muted-color);
 
28
  }
29
 
30
  @media (--bp-content-collapse) {
31
- .aside__aside {
32
- position: static;
33
- width: auto;
 
 
 
 
 
 
 
34
  margin-top: 8px;
 
 
 
 
 
 
 
35
  }
36
  }
37
  </style>
 
1
  ---
2
+
3
  ---
4
+
5
+ <div class="sidenote-container">
6
+ <aside class="sidenote">
7
  <slot />
 
 
 
8
  </aside>
9
  </div>
10
 
11
+ <script>
12
+ document.addEventListener("DOMContentLoaded", () => {
13
+ const containers = document.querySelectorAll(".sidenote-container");
14
+
15
+ containers.forEach((container) => {
16
+ // Find the previous element (sibling just before)
17
+ const previousElement = container.previousElementSibling as HTMLElement;
18
+
19
+ if (previousElement && previousElement.parentNode) {
20
+ // Create a wrapper div that will contain both the previous element and the sidenote
21
+ const wrapper = document.createElement("div");
22
+ wrapper.className = "sidenote-wrapper";
23
+
24
+ // Insert the wrapper before the previous element
25
+ previousElement.parentNode.insertBefore(wrapper, previousElement);
26
+
27
+ // Move both the previous element and the sidenote container into the wrapper
28
+ wrapper.appendChild(previousElement);
29
+ wrapper.appendChild(container);
30
+
31
+ // Style the wrapper to create the layout
32
+ wrapper.style.position = "relative";
33
+ wrapper.style.display = "block";
34
+
35
+ // Style the sidenote container so it positions correctly
36
+ const sidenoteContainer = container as HTMLElement;
37
+ sidenoteContainer.style.position = "absolute";
38
+ sidenoteContainer.style.top = "0";
39
+ sidenoteContainer.style.right = "-292px"; // 260px width + 32px gap
40
+ sidenoteContainer.style.width = "260px";
41
+
42
+ // Display the container with a fade-in
43
+ sidenoteContainer.style.display = "block";
44
+ sidenoteContainer.style.opacity = "0";
45
+
46
+ // Fade-in with transition
47
+ setTimeout(() => {
48
+ sidenoteContainer.style.opacity = "1";
49
+ }, 10);
50
+ }
51
+ });
52
+ });
53
+ </script>
54
 
55
  <style is:global>
56
+ .sidenote-wrapper {
57
+ /* Le wrapper contient l'élément original et le sidenote */
58
  position: relative;
59
+ display: block;
60
+ }
61
+
62
+ .sidenote-container {
63
+ /* Caché par défaut, sera affiché par JS */
64
+ display: none;
65
+ margin: 0;
66
+ /* Transition for fade-in */
67
+ transition: opacity 0.3s ease-in-out;
68
  }
69
 
70
+ .sidenote {
 
 
 
 
71
  border-radius: 8px;
72
  padding: 0 30px;
73
  font-size: 0.9rem;
74
  color: var(--muted-color);
75
+ margin: 0;
76
  }
77
 
78
  @media (--bp-content-collapse) {
79
+ .sidenote-wrapper {
80
+ /* Sur mobile, le wrapper n'a pas besoin de position relative */
81
+ position: static !important;
82
+ }
83
+
84
+ .sidenote-container {
85
+ position: static !important;
86
+ width: auto !important;
87
+ right: auto !important;
88
+ top: auto !important;
89
  margin-top: 8px;
90
+ /* Affichage normal sur mobile */
91
+ display: block !important;
92
+ opacity: 1 !important;
93
+ }
94
+
95
+ .sidenote {
96
+ padding: 0;
97
  }
98
  }
99
  </style>
app/src/components/Stack.astro ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ interface Props {
3
+ /** Layout mode: number of columns or 'auto' for responsive */
4
+ layout?: "2-column" | "3-column" | "4-column" | "auto";
5
+ /** Gap between items - can be a predefined size or custom value (e.g., "2rem", "20px", "1.5em") */
6
+ gap?: "small" | "medium" | "large" | string;
7
+ /** Optional class to apply on the wrapper */
8
+ class?: string;
9
+ /** Optional ID for the stack */
10
+ id?: string;
11
+ }
12
+
13
+ const {
14
+ layout = "2-column",
15
+ gap = "medium",
16
+ class: className,
17
+ id,
18
+ } = Astro.props as Props;
19
+
20
+ // Generate flex properties based on layout
21
+ const getFlexProperties = () => {
22
+ switch (layout) {
23
+ case "2-column":
24
+ return { flexBasis: "50%", maxWidth: "50%" };
25
+ case "3-column":
26
+ return { flexBasis: "33.333%", maxWidth: "33.333%" };
27
+ case "4-column":
28
+ return { flexBasis: "25%", maxWidth: "25%" };
29
+ case "auto":
30
+ return { flexBasis: "auto", maxWidth: "none" };
31
+ default:
32
+ // By default, all children on one line with equal width
33
+ return { flexBasis: "auto", maxWidth: "none" };
34
+ }
35
+ };
36
+
37
+ const getGapSize = () => {
38
+ // If it's a predefined size, return the corresponding value
39
+ switch (gap) {
40
+ case "small":
41
+ return "0.5rem";
42
+ case "medium":
43
+ return "1rem";
44
+ case "large":
45
+ return "1.5rem";
46
+ default:
47
+ // If it's a custom value, return it as-is (e.g., "2rem", "20px", "1.5em")
48
+ return gap;
49
+ }
50
+ };
51
+
52
+ const flexProps = getFlexProperties();
53
+ const gapSize = getGapSize();
54
+ ---
55
+
56
+ <div
57
+ class={`stack ${className || ""}`}
58
+ data-layout={layout}
59
+ data-gap={gap}
60
+ {id}
61
+ style={`gap: ${gapSize}`}
62
+ >
63
+ <slot />
64
+ </div>
65
+
66
+ <style>
67
+ .stack {
68
+ display: grid;
69
+ gap: 1rem;
70
+ margin: var(--block-spacing-y) 0;
71
+ width: 100%;
72
+ max-width: 100%;
73
+ box-sizing: border-box;
74
+ }
75
+
76
+ /* Layout configurations */
77
+ .stack[data-layout="2-column"] {
78
+ grid-template-columns: repeat(2, 1fr);
79
+ }
80
+
81
+ .stack[data-layout="3-column"] {
82
+ grid-template-columns: repeat(3, 1fr);
83
+ }
84
+
85
+ .stack[data-layout="4-column"] {
86
+ grid-template-columns: repeat(4, 1fr);
87
+ }
88
+
89
+ .stack[data-layout="auto"] {
90
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
91
+ }
92
+
93
+ /* Default layout (2-column) */
94
+ .stack:not([data-layout]) {
95
+ grid-template-columns: repeat(2, 1fr);
96
+ }
97
+
98
+ /* Ensure child elements don't overflow */
99
+ .stack :global(> *) {
100
+ min-width: 0 !important;
101
+ max-width: 100% !important;
102
+ box-sizing: border-box !important;
103
+ word-wrap: break-word !important;
104
+ overflow-wrap: break-word !important;
105
+ overflow: hidden !important;
106
+ }
107
+
108
+ /* Handle code blocks inside stack */
109
+ .stack pre {
110
+ overflow-x: auto;
111
+ max-width: 100%;
112
+ width: 100%;
113
+ word-wrap: break-word;
114
+ white-space: pre-wrap;
115
+ box-sizing: border-box;
116
+ min-width: 0 !important;
117
+ }
118
+
119
+ .stack code {
120
+ word-wrap: break-word;
121
+ white-space: pre-wrap;
122
+ max-width: 100%;
123
+ box-sizing: border-box;
124
+ min-width: 0 !important;
125
+ }
126
+
127
+ /* Override the min-width: 100% from _code.css */
128
+ .stack pre code {
129
+ min-width: 0 !important;
130
+ }
131
+
132
+ /* Override section.content-grid pre code min-width rule */
133
+ .stack section.content-grid pre code {
134
+ min-width: 0 !important;
135
+ }
136
+
137
+ /* Responsive behavior */
138
+ @media (max-width: 768px) {
139
+ .stack[data-layout="3-column"],
140
+ .stack[data-layout="4-column"],
141
+ .stack[data-layout="2-column"],
142
+ .stack[data-layout="auto"],
143
+ .stack:not([data-layout]) {
144
+ grid-template-columns: 1fr !important;
145
+ }
146
+ }
147
+
148
+ @media (min-width: 769px) and (max-width: 1100px) {
149
+ .stack[data-layout="3-column"],
150
+ .stack[data-layout="4-column"],
151
+ .stack[data-layout="auto"] {
152
+ grid-template-columns: repeat(2, 1fr) !important;
153
+ }
154
+ }
155
+
156
+ @media (min-width: 1101px) and (max-width: 1400px) {
157
+ .stack[data-layout="4-column"] {
158
+ grid-template-columns: repeat(2, 1fr) !important;
159
+ }
160
+ }
161
+ </style>
app/src/components/demo/ColorPicker.astro ADDED
@@ -0,0 +1,633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+
3
+ ---
4
+
5
+ <div class="color-picker" style="width:100%; margin: 10px 0;">
6
+ <style>
7
+ .color-picker .picker__stack {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 12px;
11
+ }
12
+ .color-picker .current-card {
13
+ display: grid;
14
+ grid-template-columns: 40% 60%;
15
+ align-items: center;
16
+ gap: 14px;
17
+ padding: 14px 32px 14px 16px;
18
+ border: 1px solid var(--border-color);
19
+ background: var(--surface-bg);
20
+ border-radius: 12px;
21
+ }
22
+ .color-picker .current-left {
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: 8px;
26
+ min-width: 0;
27
+ }
28
+ .color-picker .current-right {
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: 8px;
32
+ padding-left: 14px;
33
+ border-left: 1px solid var(--border-color);
34
+ }
35
+ .color-picker .current-main {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 12px;
39
+ min-width: 0;
40
+ }
41
+ .color-picker .current-swatch {
42
+ width: 64px;
43
+ height: 64px;
44
+ border-radius: 8px;
45
+ border: 1px solid var(--border-color);
46
+ }
47
+ .color-picker .current-text {
48
+ display: flex;
49
+ flex-direction: column;
50
+ line-height: 1.2;
51
+ min-width: 0;
52
+ }
53
+ .color-picker .current-name {
54
+ font-size: 14px;
55
+ font-weight: 800;
56
+ color: var(--text-color);
57
+ white-space: nowrap;
58
+ overflow: hidden;
59
+ text-overflow: ellipsis;
60
+ max-width: clamp(140px, 28vw, 260px);
61
+ }
62
+ .color-picker .current-hex,
63
+ .color-picker .current-extra {
64
+ font-size: 11px;
65
+ color: var(--muted-color);
66
+ letter-spacing: 0.02em;
67
+ white-space: nowrap;
68
+ overflow: hidden;
69
+ text-overflow: ellipsis;
70
+ max-width: clamp(140px, 28vw, 260px);
71
+ }
72
+ :global(.color-label) {
73
+ color: var(--muted-color);
74
+ opacity: 0.6 !important;
75
+ }
76
+ :global(.color-value) {
77
+ color: var(--text-color);
78
+ font-weight: 500;
79
+ }
80
+ .color-picker .picker__label {
81
+ font-weight: 700;
82
+ font-size: 12px;
83
+ color: var(--muted-color);
84
+ text-transform: uppercase;
85
+ letter-spacing: 0.02em;
86
+ }
87
+ .color-picker .hue-slider {
88
+ position: relative;
89
+ height: 16px;
90
+ border-radius: 10px;
91
+ border: 1px solid var(--border-color);
92
+ background: linear-gradient(
93
+ to right,
94
+ #f00 0%,
95
+ #ff0 17%,
96
+ #0f0 33%,
97
+ #0ff 50%,
98
+ #00f 67%,
99
+ #f0f 83%,
100
+ #f00 100%
101
+ );
102
+ cursor: ew-resize;
103
+ touch-action: none;
104
+ flex: 1 1 auto;
105
+ min-width: 200px;
106
+ }
107
+ .color-picker .hue-knob {
108
+ position: absolute;
109
+ top: 50%;
110
+ left: 93.6%;
111
+ width: 14px;
112
+ height: 14px;
113
+ border-radius: 50%;
114
+ border: 2px solid #fff;
115
+ transform: translate(-50%, -50%);
116
+ background: var(--surface-bg);
117
+ z-index: 2;
118
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
119
+ }
120
+ .color-picker .hue-slider:focus-visible {
121
+ outline: 2px solid var(--primary-color);
122
+ outline-offset: 2px;
123
+ }
124
+ .color-picker .hue-value {
125
+ font-variant-numeric: tabular-nums;
126
+ color: var(--muted-color);
127
+ font-size: 12px;
128
+ }
129
+ @media (max-width: 720px) {
130
+ .color-picker .current-card {
131
+ grid-template-columns: 1fr;
132
+ }
133
+ .color-picker .current-right {
134
+ padding-left: 0;
135
+ border-left: none;
136
+ }
137
+ }
138
+ </style>
139
+ <div class="picker__stack">
140
+ <div class="current-card">
141
+ <div class="current-left">
142
+ <div class="current-main">
143
+ <div
144
+ class="current-swatch"
145
+ aria-label="Current color"
146
+ title="Current color"
147
+ >
148
+ </div>
149
+ <div class="current-text">
150
+ <div class="current-name">—</div>
151
+ <div class="current-extra current-lch">—</div>
152
+ <div class="current-extra current-rgb">—</div>
153
+ <div class="current-hex">—</div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ <div class="current-right">
158
+ <div class="picker__label">Hue</div>
159
+ <div
160
+ class="hue-slider"
161
+ role="slider"
162
+ aria-label="Hue"
163
+ aria-valuemin="0"
164
+ aria-valuemax="360"
165
+ aria-valuenow="214"
166
+ tabindex="0"
167
+ >
168
+ <div class="hue-knob"></div>
169
+ </div>
170
+ <div class="hue-value">214°</div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ <script>
176
+ (() => {
177
+ const COLOR_NAMES = [
178
+ { name: "Candy Apple Red", hex: "#ff0800" },
179
+ { name: "Boiling Magma", hex: "#ff3300" },
180
+ { name: "Aerospace Orange", hex: "#ff4f00" },
181
+ { name: "Burtuqali Orange", hex: "#ff6700" },
182
+ { name: "American Orange", hex: "#ff8b00" },
183
+ { name: "Cheese", hex: "#ffa600" },
184
+ { name: "Amber", hex: "#ffbf00" },
185
+ { name: "Demonic Yellow", hex: "#ffe700" },
186
+ { name: "Bat-Signal", hex: "#feff00" },
187
+ { name: "Bitter Lime", hex: "#cfff00" },
188
+ { name: "Electric Lime", hex: "#ccff00" },
189
+ { name: "Bright Yellow Green", hex: "#9dff00" },
190
+ { name: "Lasting Lime", hex: "#88ff00" },
191
+ { name: "Bright Green", hex: "#66ff00" },
192
+ { name: "Chlorophyll Green", hex: "#4aff00" },
193
+ { name: "Green Screen", hex: "#22ff00" },
194
+ { name: "Electric Pickle", hex: "#00ff04" },
195
+ { name: "Acid", hex: "#00ff22" },
196
+ { name: "Lucent Lime", hex: "#00ff33" },
197
+ { name: "Cathode Green", hex: "#00ff55" },
198
+ { name: "Booger Buster", hex: "#00ff77" },
199
+ { name: "Green Gas", hex: "#00ff99" },
200
+ { name: "Enthusiasm", hex: "#00ffaa" },
201
+ { name: "Ice Ice Baby", hex: "#00ffdd" },
202
+ { name: "Master Sword Blue", hex: "#00ffee" },
203
+ { name: "Agressive Aqua", hex: "#00fbff" },
204
+ { name: "Vivid Sky Blue", hex: "#00ccff" },
205
+ { name: "Capri", hex: "#00bfff" },
206
+ { name: "Sky of Magritte", hex: "#0099ff" },
207
+ { name: "Azure", hex: "#007fff" },
208
+ { name: "Blue Ribbon", hex: "#0066ff" },
209
+ { name: "Blinking Blue", hex: "#0033ff" },
210
+ { name: "Icelandic Water", hex: "#0011ff" },
211
+ { name: "Blue", hex: "#0000ff" },
212
+ { name: "Blue Pencil", hex: "#2200ff" },
213
+ { name: "Electric Ultramarine", hex: "#3f00ff" },
214
+ { name: "Aladdin's Feather", hex: "#5500ff" },
215
+ { name: "Purple Climax", hex: "#8800ff" },
216
+ { name: "Amethyst Ganzstar", hex: "#8f00ff" },
217
+ { name: "Electric Purple", hex: "#bf00ff" },
218
+ { name: "Phlox", hex: "#df00ff" },
219
+ { name: "Brusque Pink", hex: "#ee00ff" },
220
+ { name: "Bright Magenta", hex: "#ff08e8" },
221
+ { name: "Big bang Pink", hex: "#ff00bb" },
222
+ { name: "Mean Girls Lipstick", hex: "#ff00ae" },
223
+ { name: "Pink", hex: "#ff0099" },
224
+ { name: "Hot Flamingoes", hex: "#ff005d" },
225
+ { name: "Blazing Dragonfruit", hex: "#ff0054" },
226
+ { name: "Carmine Red", hex: "#ff0038" },
227
+ { name: "Bright Red", hex: "#ff000d" },
228
+ ];
229
+ if (!window.__colorNames) window.__colorNames = COLOR_NAMES;
230
+
231
+ if (!window.__colorPickerBus) {
232
+ window.__colorPickerBus = (() => {
233
+ let hue = 214;
234
+ let adjusting = false;
235
+ const listeners = new Set();
236
+ return {
237
+ get: () => ({ hue, adjusting }),
238
+ publish: (sourceId, nextHue, isAdj) => {
239
+ hue = ((nextHue % 360) + 360) % 360;
240
+ adjusting = !!isAdj;
241
+ listeners.forEach((fn) => {
242
+ try {
243
+ fn({ sourceId, hue, adjusting });
244
+ } catch {}
245
+ });
246
+ },
247
+ subscribe: (fn) => {
248
+ listeners.add(fn);
249
+ return () => listeners.delete(fn);
250
+ },
251
+ };
252
+ })();
253
+ }
254
+
255
+ const bootstrap = () => {
256
+ const root = document.querySelector(".color-picker");
257
+ if (!root || root.dataset.mounted) return;
258
+ root.dataset.mounted = "true";
259
+ const slider = root.querySelector(".hue-slider");
260
+ const knob = root.querySelector(".hue-knob");
261
+ const hueValue = root.querySelector(".hue-value");
262
+ const currentSwatch = root.querySelector(".current-swatch");
263
+ const currentName = root.querySelector(".current-name");
264
+ const currentHex = root.querySelector(".current-hex");
265
+ const currentLch = root.querySelector(".current-lch");
266
+ const currentRgb = root.querySelector(".current-rgb");
267
+ const bus = window.__colorPickerBus;
268
+ const instanceId = Math.random().toString(36).slice(2);
269
+ const getKnobRadius = () => {
270
+ try {
271
+ const w = knob ? knob.getBoundingClientRect().width : 0;
272
+ return w ? w / 2 : 8;
273
+ } catch {
274
+ return 8;
275
+ }
276
+ };
277
+ const hexToHsl = (H) => {
278
+ const s = H.replace("#", "");
279
+ const v =
280
+ s.length === 3
281
+ ? s
282
+ .split("")
283
+ .map((ch) => ch + ch)
284
+ .join("")
285
+ : s;
286
+ const bigint = parseInt(v, 16);
287
+ let r = (bigint >> 16) & 255,
288
+ g = (bigint >> 8) & 255,
289
+ b = bigint & 255;
290
+ r /= 255;
291
+ g /= 255;
292
+ b /= 255;
293
+ const max = Math.max(r, g, b),
294
+ min = Math.min(r, g, b);
295
+ let h = 0,
296
+ s2 = 0,
297
+ l = (max + min) / 2;
298
+ if (max !== min) {
299
+ const d = max - min;
300
+ s2 = l > 0.5 ? d / (2 - max - min) : d / (max + min);
301
+ switch (max) {
302
+ case r:
303
+ h = (g - b) / d + (g < b ? 6 : 0);
304
+ break;
305
+ case g:
306
+ h = (b - r) / d + 2;
307
+ break;
308
+ default:
309
+ h = (r - g) / d + 4;
310
+ }
311
+ h /= 6;
312
+ }
313
+ return {
314
+ h: Math.round(h * 360),
315
+ s: Math.round(s2 * 100),
316
+ l: Math.round(l * 100),
317
+ };
318
+ };
319
+ const hslToHex = (h, s, l) => {
320
+ s /= 100;
321
+ l /= 100;
322
+ const k = (n) => (n + h / 30) % 12;
323
+ const a = s * Math.min(l, 1 - l);
324
+ const f = (n) =>
325
+ l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
326
+ const toHex = (x) =>
327
+ Math.round(255 * x)
328
+ .toString(16)
329
+ .padStart(2, "0");
330
+ return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`.toUpperCase();
331
+ };
332
+
333
+ // OKLCH conversion functions
334
+ const srgbToLinear = (u) =>
335
+ u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4);
336
+ const linearToSrgb = (u) =>
337
+ u <= 0.0031308
338
+ ? 12.92 * u
339
+ : 1.055 * Math.pow(Math.max(0, u), 1 / 2.4) - 0.055;
340
+ const rgbToOklab = (r, g, b) => {
341
+ const rl = srgbToLinear(r),
342
+ gl = srgbToLinear(g),
343
+ bl = srgbToLinear(b);
344
+ const l = Math.cbrt(
345
+ 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl,
346
+ );
347
+ const m = Math.cbrt(
348
+ 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl,
349
+ );
350
+ const s = Math.cbrt(
351
+ 0.0883024619 * rl + 0.2817188366 * gl + 0.6299787005 * bl,
352
+ );
353
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
354
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
355
+ const b2 = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
356
+ return { L, a, b: b2 };
357
+ };
358
+ const oklabToOklch = (L, a, b) => {
359
+ const C = Math.sqrt(a * a + b * b);
360
+ let h = (Math.atan2(b, a) * 180) / Math.PI;
361
+ if (h < 0) h += 360;
362
+ return { L, C, h };
363
+ };
364
+ const hexToOklch = (hex) => {
365
+ const s = hex.replace("#", "");
366
+ const v =
367
+ s.length === 3
368
+ ? s
369
+ .split("")
370
+ .map((ch) => ch + ch)
371
+ .join("")
372
+ : s;
373
+ const r = parseInt(v.slice(0, 2), 16) / 255;
374
+ const g = parseInt(v.slice(2, 4), 16) / 255;
375
+ const b = parseInt(v.slice(4, 6), 16) / 255;
376
+ const { L, a, b: bb } = rgbToOklab(r, g, b);
377
+ return oklabToOklch(L, a, bb);
378
+ };
379
+ // Precompute hues for the provided color-name list
380
+ const NAME_HUES = COLOR_NAMES.map((c) => {
381
+ const hh = hexToHsl(c.hex).h || 0;
382
+ return { name: c.name, hue: hh };
383
+ });
384
+ // Pick closest name by circular hue distance; fallback to coarse labels
385
+ const getName = (hex) => {
386
+ const h = hexToHsl(hex).h || 0;
387
+ let bestName = "—";
388
+ let best = 361;
389
+ for (let i = 0; i < NAME_HUES.length; i++) {
390
+ const hh = NAME_HUES[i].hue;
391
+ const d = Math.abs(hh - h);
392
+ const dist = Math.min(d, 360 - d);
393
+ if (dist < best) {
394
+ best = dist;
395
+ bestName = NAME_HUES[i].name;
396
+ }
397
+ }
398
+ if (bestName !== "—") return bestName;
399
+ const labels = [
400
+ "Red",
401
+ "Orange",
402
+ "Yellow",
403
+ "Lime",
404
+ "Green",
405
+ "Cyan",
406
+ "Blue",
407
+ "Indigo",
408
+ "Violet",
409
+ "Magenta",
410
+ ];
411
+ const idx = Math.round(((h % 360) / 360) * (labels.length - 1));
412
+ return labels[idx];
413
+ };
414
+ // OKLCH to RGB conversion functions
415
+ const oklchToOklab = (L, C, hDeg) => {
416
+ const h = (hDeg * Math.PI) / 180;
417
+ return { L, a: C * Math.cos(h), b: C * Math.sin(h) };
418
+ };
419
+ const oklabToRgb = (L, a, b) => {
420
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
421
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
422
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
423
+ const l = l_ * l_ * l_;
424
+ const m = m_ * m_ * m_;
425
+ const s = s_ * s_ * s_;
426
+ const r = linearToSrgb(
427
+ +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
428
+ );
429
+ const g = linearToSrgb(
430
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
431
+ );
432
+ const b3 = linearToSrgb(
433
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
434
+ );
435
+ return { r, g, b: b3 };
436
+ };
437
+ const oklchToHex = (L, C, h) => {
438
+ const { a, b } = oklchToOklab(L, C, h);
439
+ const rgb = oklabToRgb(L, a, b);
440
+ const toHex = (x) =>
441
+ Math.round(255 * x)
442
+ .toString(16)
443
+ .padStart(2, "0");
444
+ return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`.toUpperCase();
445
+ };
446
+
447
+ const updateUI = (h, adjusting) => {
448
+ const rect = slider.getBoundingClientRect();
449
+ const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
450
+ const t = Math.max(0, Math.min(1, h / 360));
451
+ const leftPx = r + t * Math.max(0, rect.width - 2 * r);
452
+ if (knob) knob.style.left = (leftPx / rect.width) * 100 + "%";
453
+ if (hueValue) hueValue.innerHTML = `<strong>${Math.round(h)}°</strong>`;
454
+ if (slider) slider.setAttribute("aria-valuenow", String(Math.round(h)));
455
+
456
+ // Generate OKLCH color directly (similar to CSS variables)
457
+ const L = 0.75; // 75% lightness
458
+ const C = 0.12; // 12% chroma
459
+ const oklchColor = `oklch(${L} ${C} ${h})`;
460
+ const baseHex = oklchToHex(L, C, h);
461
+
462
+ if (currentSwatch) currentSwatch.style.background = baseHex;
463
+ if (currentName) currentName.textContent = getName(baseHex);
464
+ if (currentHex)
465
+ currentHex.innerHTML = `<span class="color-label">#</span><span class="color-value">${baseHex.replace("#", "")}</span>`;
466
+
467
+ // Display OKLCH values
468
+ if (currentLch)
469
+ currentLch.innerHTML = `<span class="color-label">OKLCH</span> <span class="color-value">${(L * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value">${(C * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value"><strong>${Math.round(h)}</strong></span><span class="color-label">°</span>`;
470
+
471
+ if (currentRgb) {
472
+ const hex = baseHex.replace("#", "");
473
+ const R = parseInt(hex.slice(0, 2), 16),
474
+ G = parseInt(hex.slice(2, 4), 16),
475
+ B = parseInt(hex.slice(4, 6), 16);
476
+ currentRgb.innerHTML = `<span class="color-label">RGB</span> <span class="color-value">${R}</span><span class="color-label">,</span> <span class="color-value">${G}</span><span class="color-label">,</span> <span class="color-value">${B}</span>`;
477
+ }
478
+
479
+ // Apply OKLCH color to CSS variables
480
+ const hoverOklch = `oklch(${L} ${C} ${h})`;
481
+ const rootEl = document.documentElement;
482
+ rootEl.style.setProperty("--primary-color", oklchColor);
483
+ rootEl.style.setProperty("--primary-color-hover", hoverOklch);
484
+ };
485
+
486
+ // Update UI position only, without modifying CSS colors
487
+ const updateUIPositionOnly = (h) => {
488
+ const rect = slider.getBoundingClientRect();
489
+ const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
490
+ const t = Math.max(0, Math.min(1, h / 360));
491
+ const leftPx = r + t * Math.max(0, rect.width - 2 * r);
492
+ if (knob) knob.style.left = (leftPx / rect.width) * 100 + "%";
493
+ if (hueValue) hueValue.innerHTML = `<strong>${Math.round(h)}°</strong>`;
494
+ if (slider) slider.setAttribute("aria-valuenow", String(Math.round(h)));
495
+
496
+ // Generate OKLCH color for display only
497
+ const L = 0.75; // 75% lightness
498
+ const C = 0.12; // 12% chroma
499
+ const baseHex = oklchToHex(L, C, h);
500
+
501
+ if (currentSwatch) currentSwatch.style.background = baseHex;
502
+ if (currentName) currentName.textContent = getName(baseHex);
503
+ if (currentHex)
504
+ currentHex.innerHTML = `<span class="color-label">#</span><span class="color-value">${baseHex.replace("#", "")}</span>`;
505
+
506
+ // Display OKLCH values
507
+ if (currentLch)
508
+ currentLch.innerHTML = `<span class="color-label">OKLCH</span> <span class="color-value">${(L * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value">${(C * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value"><strong>${Math.round(h)}</strong></span><span class="color-label">°</span>`;
509
+
510
+ if (currentRgb) {
511
+ const hex = baseHex.replace("#", "");
512
+ const R = parseInt(hex.slice(0, 2), 16),
513
+ G = parseInt(hex.slice(2, 4), 16),
514
+ B = parseInt(hex.slice(4, 6), 16);
515
+ currentRgb.innerHTML = `<span class="color-label">RGB</span> <span class="color-value">${R}</span><span class="color-label">,</span> <span class="color-value">${G}</span><span class="color-label">,</span> <span class="color-value">${B}</span>`;
516
+ }
517
+
518
+ // DO NOT modify CSS variables - just show the position
519
+ };
520
+ const getHueFromEvent = (ev) => {
521
+ const rect = slider.getBoundingClientRect();
522
+ const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
523
+ const x = clientX - rect.left;
524
+ const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
525
+ const effX = Math.max(r, Math.min(rect.width - r, x));
526
+ const denom = Math.max(1, rect.width - 2 * r);
527
+ const t = (effX - r) / denom;
528
+ return t * 360;
529
+ };
530
+ const unsubscribe = bus.subscribe(({ sourceId, hue, adjusting }) => {
531
+ if (sourceId === instanceId) return;
532
+ updateUI(hue, adjusting);
533
+ });
534
+ try {
535
+ let initH = 214;
536
+
537
+ // Try to get OKLCH directly from ColorPalettes first
538
+ if (
539
+ window.ColorPalettes &&
540
+ typeof window.ColorPalettes.getPrimaryOKLCH === "function"
541
+ ) {
542
+ const oklch = window.ColorPalettes.getPrimaryOKLCH();
543
+ if (oklch && oklch.h !== undefined) {
544
+ initH = oklch.h; // Use exact OKLCH hue, no conversion!
545
+ }
546
+ } else {
547
+ // Fallback: try to parse OKLCH directly from CSS
548
+ const cssPrimary = getComputedStyle(document.documentElement)
549
+ .getPropertyValue("--primary-color")
550
+ .trim();
551
+ if (cssPrimary && cssPrimary.includes("oklch")) {
552
+ const oklchMatch = cssPrimary.match(/oklch\(([^)]+)\)/);
553
+ if (oklchMatch) {
554
+ const values = oklchMatch[1]
555
+ .split(/\s+/)
556
+ .map((v) => parseFloat(v.trim()));
557
+ if (values.length >= 3) {
558
+ initH = values[2]; // Direct OKLCH hue, no conversion!
559
+ }
560
+ }
561
+ } else if (cssPrimary) {
562
+ // Only convert if it's not OKLCH
563
+ initH = hexToHsl(cssPrimary).h || initH;
564
+ }
565
+ }
566
+
567
+ const { hue: sharedHue } = bus.get();
568
+ // Only update UI position, don't modify CSS colors on initialization
569
+ updateUIPositionOnly(initH ?? sharedHue);
570
+ bus.publish(instanceId, initH ?? sharedHue, false);
571
+ } catch {
572
+ const { hue: sharedHue } = bus.get();
573
+ updateUIPositionOnly(sharedHue);
574
+ }
575
+ const onDown = (ev) => {
576
+ ev.preventDefault();
577
+ const h = getHueFromEvent(ev);
578
+ updateUI(h, true);
579
+ bus.publish(instanceId, h, true);
580
+ const move = (e) => {
581
+ e.preventDefault && e.preventDefault();
582
+ const hh = getHueFromEvent(e);
583
+ updateUI(hh, true);
584
+ bus.publish(instanceId, hh, true);
585
+ };
586
+ const up = () => {
587
+ bus.publish(instanceId, getHueFromEvent(ev), false);
588
+ window.removeEventListener("mousemove", move);
589
+ window.removeEventListener("touchmove", move);
590
+ window.removeEventListener("mouseup", up);
591
+ window.removeEventListener("touchend", up);
592
+ };
593
+ window.addEventListener("mousemove", move, { passive: false });
594
+ window.addEventListener("touchmove", move, { passive: false });
595
+ window.addEventListener("mouseup", up, { once: true });
596
+ window.addEventListener("touchend", up, { once: true });
597
+ };
598
+ if (slider) {
599
+ slider.addEventListener("mousedown", onDown);
600
+ slider.addEventListener("touchstart", onDown, { passive: false });
601
+ slider.addEventListener("keydown", (e) => {
602
+ const step = e.shiftKey ? 10 : 2;
603
+ if (e.key === "ArrowLeft") {
604
+ e.preventDefault();
605
+ const { hue } = bus.get();
606
+ const h = hue - step;
607
+ updateUI(h, true);
608
+ bus.publish(instanceId, h, true);
609
+ bus.publish(instanceId, h, false);
610
+ }
611
+ if (e.key === "ArrowRight") {
612
+ e.preventDefault();
613
+ const { hue } = bus.get();
614
+ const h = hue + step;
615
+ updateUI(h, true);
616
+ bus.publish(instanceId, h, true);
617
+ bus.publish(instanceId, h, false);
618
+ }
619
+ });
620
+ }
621
+ const ro = new MutationObserver(() => {
622
+ if (!document.body.contains(root)) {
623
+ unsubscribe && unsubscribe();
624
+ ro.disconnect();
625
+ }
626
+ });
627
+ ro.observe(document.body, { childList: true, subtree: true });
628
+ };
629
+ if (document.readyState === "loading")
630
+ document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
631
+ else bootstrap();
632
+ })();
633
+ </script>
app/src/components/demo/Palettes.astro ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ const rootId = `palettes-${Math.random().toString(36).slice(2)}`;
3
+ ---
4
+
5
+ <div class="palettes" id={rootId} style="width:100%; margin: 10px 0;">
6
+ <style is:global>
7
+ .palettes {
8
+ box-sizing: border-box;
9
+ }
10
+ .palettes .palettes__grid {
11
+ display: grid;
12
+ grid-template-columns: 1fr;
13
+ gap: 12px;
14
+ max-width: 100%;
15
+ }
16
+ .palettes .palette-card {
17
+ position: relative;
18
+ display: grid;
19
+ grid-template-columns: 1fr minmax(0, 220px);
20
+ align-items: stretch;
21
+ gap: 12px;
22
+ border: 1px solid var(--border-color);
23
+ border-radius: 10px;
24
+ background: var(--surface-bg);
25
+ padding: 12px;
26
+ transition:
27
+ box-shadow 0.18s ease,
28
+ transform 0.18s ease,
29
+ border-color 0.18s ease;
30
+ min-height: 60px;
31
+ }
32
+ .palettes .palette-card__preview {
33
+ width: 48px;
34
+ height: 48px;
35
+ border-radius: 999px;
36
+ flex: 0 0 auto;
37
+ background-size: cover;
38
+ background-position: center;
39
+ }
40
+ .palettes .palette-card__copy {
41
+ position: absolute;
42
+ top: 50%;
43
+ left: 100%;
44
+ transform: translateY(-50%);
45
+ z-index: 3;
46
+ border-left: none;
47
+ border-top-left-radius: 0;
48
+ border-bottom-left-radius: 0;
49
+ }
50
+ .palettes .palette-card__copy svg {
51
+ width: 18px;
52
+ height: 18px;
53
+ fill: currentColor;
54
+ display: block;
55
+ color: inherit;
56
+ }
57
+ .palettes .palette-card__swatches {
58
+ display: grid;
59
+ grid-template-columns: repeat(6, minmax(0, 1fr));
60
+ grid-auto-rows: 1fr;
61
+ gap: 2px;
62
+ margin: 0;
63
+ min-height: 60px;
64
+ }
65
+ .palettes .palette-card__swatches .sw {
66
+ width: 100%;
67
+ min-width: 0;
68
+ height: auto;
69
+ border-radius: 0;
70
+ border: 1px solid var(--border-color);
71
+ }
72
+ .palettes .palette-card__swatches .sw:first-child {
73
+ border-top-left-radius: 8px;
74
+ border-bottom-left-radius: 8px;
75
+ }
76
+ .palettes .palette-card__swatches .sw:last-child {
77
+ border-top-right-radius: 8px;
78
+ border-bottom-right-radius: 8px;
79
+ }
80
+ .palettes .palette-card__content {
81
+ display: flex;
82
+ flex-direction: row;
83
+ align-items: center;
84
+ justify-content: flex-start;
85
+ gap: 12px;
86
+ min-width: 0;
87
+ padding-right: 12px;
88
+ }
89
+ .palettes .palette-card__preview {
90
+ width: 48px;
91
+ height: 48px;
92
+ border-radius: 999px;
93
+ position: relative;
94
+ flex: 0 0 auto;
95
+ overflow: hidden;
96
+ }
97
+ .palettes .palette-card__preview .dot {
98
+ position: absolute;
99
+ width: 4px;
100
+ height: 4px;
101
+ background: #fff;
102
+ border-radius: 999px;
103
+ box-shadow: 0 0 6px rgba(0, 0, 0, 1);
104
+ }
105
+ .palettes .palette-card__preview .donut-hole {
106
+ position: absolute;
107
+ left: 50%;
108
+ top: 50%;
109
+ transform: translate(-50%, -50%);
110
+ width: 24px;
111
+ height: 24px;
112
+ border-radius: 999px;
113
+ background: var(--surface-bg);
114
+ box-shadow: 0 0 0 1px var(--border-color) inset;
115
+ }
116
+
117
+ .palettes .palette-card__content__info {
118
+ display: flex;
119
+ flex-direction: column;
120
+ }
121
+ .palettes .palette-card__title {
122
+ text-align: left;
123
+ font-weight: 800;
124
+ font-size: 15px;
125
+ }
126
+ .palettes .palette-card__desc {
127
+ text-align: left;
128
+ color: var(--muted-color);
129
+ line-height: 1.5;
130
+ font-size: 12px;
131
+ }
132
+
133
+ .palettes .palettes__select {
134
+ width: 100%;
135
+ max-width: 100%;
136
+ border: 1px solid var(--border-color);
137
+ background: var(--surface-bg);
138
+ color: var(--text-color);
139
+ padding: 8px 10px;
140
+ border-radius: 8px;
141
+ }
142
+ .palettes .sr-only {
143
+ position: absolute;
144
+ width: 1px;
145
+ height: 1px;
146
+ padding: 0;
147
+ margin: -1px;
148
+ overflow: hidden;
149
+ clip: rect(0, 0, 1px, 1px);
150
+ white-space: nowrap;
151
+ border: 0;
152
+ }
153
+ .palettes .palettes__controls {
154
+ display: flex;
155
+ flex-wrap: wrap;
156
+ gap: 16px;
157
+ align-items: center;
158
+ margin: 8px 0 14px;
159
+ }
160
+ .palettes .palettes__field {
161
+ display: flex;
162
+ flex-direction: column;
163
+ gap: 6px;
164
+ min-width: 0;
165
+ flex: 1 1 280px;
166
+ max-width: 100%;
167
+ }
168
+ .palettes .palettes__label {
169
+ font-size: 12px;
170
+ color: var(--muted-color);
171
+ font-weight: 800;
172
+ }
173
+ .palettes .palettes__label-row {
174
+ display: flex;
175
+ align-items: center;
176
+ justify-content: space-between;
177
+ gap: 10px;
178
+ }
179
+ .palettes .ghost-badge {
180
+ font-size: 11px;
181
+ padding: 1px 6px;
182
+ border-radius: 999px;
183
+ border: 1px solid var(--border-color);
184
+ color: var(--muted-color);
185
+ background: transparent;
186
+ font-variant-numeric: tabular-nums;
187
+ }
188
+ .palettes .palettes__count {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: 8px;
192
+ max-width: 100%;
193
+ }
194
+ .palettes .palettes__count input[type="range"] {
195
+ width: 100%;
196
+ }
197
+ .palettes .palettes__count output {
198
+ min-width: 28px;
199
+ text-align: center;
200
+ font-variant-numeric: tabular-nums;
201
+ font-size: 12px;
202
+ color: var(--muted-color);
203
+ }
204
+ .palettes input[type="range"] {
205
+ -webkit-appearance: none;
206
+ appearance: none;
207
+ height: 24px;
208
+ background: transparent;
209
+ cursor: pointer;
210
+ accent-color: var(--primary-color);
211
+ }
212
+ .palettes input[type="range"]:focus {
213
+ outline: none;
214
+ }
215
+ .palettes input[type="range"]::-webkit-slider-runnable-track {
216
+ height: 6px;
217
+ background: var(--border-color);
218
+ border-radius: 999px;
219
+ }
220
+ .palettes input[type="range"]::-webkit-slider-thumb {
221
+ -webkit-appearance: none;
222
+ appearance: none;
223
+ margin-top: -6px;
224
+ width: 18px;
225
+ height: 18px;
226
+ background: var(--primary-color);
227
+ border: 2px solid var(--surface-bg);
228
+ border-radius: 50%;
229
+ }
230
+ .palettes input[type="range"]::-moz-range-track {
231
+ height: 6px;
232
+ background: var(--border-color);
233
+ border: none;
234
+ border-radius: 999px;
235
+ }
236
+ .palettes input[type="range"]::-moz-range-progress {
237
+ height: 6px;
238
+ background: var(--primary-color);
239
+ border-radius: 999px;
240
+ }
241
+ .palettes input[type="range"]::-moz-range-thumb {
242
+ width: 18px;
243
+ height: 18px;
244
+ background: var(--primary-color);
245
+ border: 2px solid var(--surface-bg);
246
+ border-radius: 50%;
247
+ }
248
+ html.cb-grayscale,
249
+ body.cb-grayscale {
250
+ filter: grayscale(1) !important;
251
+ }
252
+ html.cb-protanopia,
253
+ body.cb-protanopia {
254
+ filter: url(#cb-protanopia) !important;
255
+ }
256
+ html.cb-deuteranopia,
257
+ body.cb-deuteranopia {
258
+ filter: url(#cb-deuteranopia) !important;
259
+ }
260
+ html.cb-tritanopia,
261
+ body.cb-tritanopia {
262
+ filter: url(#cb-tritanopia) !important;
263
+ }
264
+ html.cb-achromatopsia,
265
+ body.cb-achromatopsia {
266
+ filter: url(#cb-achromatopsia) !important;
267
+ }
268
+ @media (max-width: 1100px) {
269
+ .palettes .palette-card {
270
+ grid-template-columns: 1fr;
271
+ align-items: stretch;
272
+ gap: 10px;
273
+ }
274
+ .palettes .palette-card__swatches {
275
+ grid-template-columns: repeat(6, minmax(0, 1fr));
276
+ }
277
+ .palettes .palette-card__content {
278
+ border-right: none;
279
+ padding-right: 0;
280
+ }
281
+ .palettes .palette-card__copy {
282
+ display: none;
283
+ }
284
+ }
285
+ </style>
286
+ <div class="palettes__controls">
287
+ <div class="palettes__field">
288
+ <label class="palettes__label" for="cb-select"
289
+ >Color vision simulation</label
290
+ >
291
+ <select id="cb-select" class="palettes__select">
292
+ <option value="none"
293
+ >Normal color vision — typical for most people</option
294
+ >
295
+ <option value="achromatopsia">Achromatopsia — no color at all</option>
296
+ <option value="protanopia">Protanopia — reduced/absent reds</option>
297
+ <option value="deuteranopia"
298
+ >Deuteranopia — reduced/absent greens</option
299
+ >
300
+ <option value="tritanopia">Tritanopia — reduced/absent blues</option>
301
+ </select>
302
+ </div>
303
+ <div class="palettes__field">
304
+ <div class="palettes__label-row">
305
+ <label class="palettes__label" for="color-count">Number of colors</label
306
+ >
307
+ <output id="color-count-out" for="color-count" class="ghost-badge"
308
+ >8</output
309
+ >
310
+ </div>
311
+ <div class="palettes__count">
312
+ <input
313
+ id="color-count"
314
+ type="range"
315
+ min="6"
316
+ max="10"
317
+ step="1"
318
+ value="8"
319
+ aria-label="Number of colors"
320
+ />
321
+ </div>
322
+ </div>
323
+ </div>
324
+ <div class="palettes__grid"></div>
325
+ <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
326
+ <svg
327
+ aria-hidden="true"
328
+ focusable="false"
329
+ width="0"
330
+ height="0"
331
+ style="position:absolute; left:-9999px; overflow:hidden;"
332
+ >
333
+ <defs>
334
+ <filter id="cb-protanopia"
335
+ ><feColorMatrix
336
+ type="matrix"
337
+ values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0"
338
+ ></feColorMatrix></filter
339
+ >
340
+ <filter id="cb-deuteranopia"
341
+ ><feColorMatrix
342
+ type="matrix"
343
+ values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0"
344
+ ></feColorMatrix></filter
345
+ >
346
+ <filter id="cb-tritanopia"
347
+ ><feColorMatrix
348
+ type="matrix"
349
+ values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0"
350
+ ></feColorMatrix></filter
351
+ >
352
+ <filter id="cb-achromatopsia"
353
+ ><feColorMatrix
354
+ type="matrix"
355
+ values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0"
356
+ ></feColorMatrix></filter
357
+ >
358
+ </defs>
359
+ </svg>
360
+ </div>
361
+ </div>
362
+ <script type="module" is:inline>
363
+ import "/scripts/color-palettes.js";
364
+ const ROOT_ID = "{rootId}";
365
+ (() => {
366
+ const cards = [
367
+ {
368
+ key: "categorical",
369
+ title: "Categorical",
370
+ desc: "For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors.",
371
+ },
372
+ {
373
+ key: "sequential",
374
+ title: "Sequential",
375
+ desc: "For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.",
376
+ },
377
+ {
378
+ key: "diverging",
379
+ title: "Diverging",
380
+ desc: "For numeric scales with negative and positive; Opposing extremes with smooth contrast around a neutral midpoint.",
381
+ },
382
+ ];
383
+ const getPaletteColors = (key, count) => {
384
+ const total = Number(count) || 6;
385
+ if (
386
+ window.ColorPalettes &&
387
+ typeof window.ColorPalettes.getColors === "function"
388
+ ) {
389
+ return window.ColorPalettes.getColors(key, total) || [];
390
+ }
391
+ return [];
392
+ };
393
+ const render = () => {
394
+ const root =
395
+ document.getElementById(ROOT_ID) || document.querySelector(".palettes");
396
+ if (!root) return;
397
+ const grid = root.querySelector(".palettes__grid");
398
+ if (!grid) return;
399
+ const input = document.getElementById("color-count");
400
+ const total = input ? Number(input.value) || 6 : 6;
401
+ const html = cards
402
+ .map((c) => {
403
+ const colors = getPaletteColors(c.key, total);
404
+ const swatches = colors
405
+ .map(
406
+ (col) => `<div class=\"sw\" style=\"background:${col}\"></div>`,
407
+ )
408
+ .join("");
409
+ const baseHex =
410
+ window.ColorPalettes &&
411
+ typeof window.ColorPalettes.getPrimary === "function"
412
+ ? window.ColorPalettes.getPrimary()
413
+ : colors[0] || "#FF0000";
414
+ const hueDeg = (() => {
415
+ try {
416
+ const s = baseHex.replace("#", "");
417
+ const v =
418
+ s.length === 3
419
+ ? s
420
+ .split("")
421
+ .map((ch) => ch + ch)
422
+ .join("")
423
+ : s;
424
+ const r = parseInt(v.slice(0, 2), 16) / 255,
425
+ g = parseInt(v.slice(2, 4), 16) / 255,
426
+ b = parseInt(v.slice(4, 6), 16) / 255;
427
+ const M = Math.max(r, g, b),
428
+ m = Math.min(r, g, b),
429
+ d = M - m;
430
+ if (d === 0) return 0;
431
+ let h = 0;
432
+ if (M === r) h = ((g - b) / d) % 6;
433
+ else if (M === g) h = (b - r) / d + 2;
434
+ else h = (r - g) / d + 4;
435
+ h *= 60;
436
+ if (h < 0) h += 360;
437
+ return h;
438
+ } catch {
439
+ return 0;
440
+ }
441
+ })();
442
+ const gradient =
443
+ c.key === "categorical"
444
+ ? (() => {
445
+ const steps = 60; // smooth hue wheel (fixed orientation)
446
+ const wheel = Array.from(
447
+ { length: steps },
448
+ (_, i) =>
449
+ `hsl(${Math.round((i / steps) * 360)}, 100%, 50%)`,
450
+ ).join(", ");
451
+ return `conic-gradient(${wheel})`;
452
+ })()
453
+ : colors.length
454
+ ? `linear-gradient(90deg, ${colors.join(", ")})`
455
+ : `linear-gradient(90deg, var(--border-color), var(--border-color))`;
456
+ const previewInner = (() => {
457
+ if (c.key !== "categorical" || !colors.length) return "";
458
+ const ring = 18;
459
+ const cx = 24;
460
+ const cy = 24;
461
+ const offset = (hueDeg / 360) * 2 * Math.PI;
462
+ return colors
463
+ .map((col, i) => {
464
+ const angle = offset + (i / colors.length) * 2 * Math.PI;
465
+ const x = cx + ring * Math.cos(angle);
466
+ const y = cy + ring * Math.sin(angle);
467
+ return `<span class=\"dot\" style=\"left:${x - 2}px; top:${y - 2}px\"></span>`;
468
+ })
469
+ .join("");
470
+ })();
471
+ const donutHole =
472
+ c.key === "categorical" ? '<span class="donut-hole"></span>' : "";
473
+ return `
474
+ <div class="palette-card" data-colors="${colors.join(",")}">
475
+ <div class="palette-card__content">
476
+ <div class=\"palette-card__preview\" aria-hidden=\"true\" style=\"background:${gradient}\">${previewInner}${donutHole}</div>
477
+ <div class="palette-card__content__info">
478
+ <div class="palette-card__title">${c.title}</div>
479
+ <div class="palette-card__desc">${c.desc}</div>
480
+ </div>
481
+ </div>
482
+ <div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div>
483
+ <button class="palette-card__copy button--ghost" type="button" aria-label="Copy palette">
484
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
485
+ </button>
486
+ </div>`;
487
+ })
488
+ .join("");
489
+ grid.innerHTML = html;
490
+ };
491
+ const MODE_TO_CLASS = {
492
+ protanopia: "cb-protanopia",
493
+ deuteranopia: "cb-deuteranopia",
494
+ tritanopia: "cb-tritanopia",
495
+ achromatopsia: "cb-achromatopsia",
496
+ };
497
+ const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
498
+ const clearCbClasses = () => {
499
+ const rootEl = document.documentElement;
500
+ CLEAR_CLASSES.forEach((cls) => rootEl.classList.remove(cls));
501
+ };
502
+ const applyCbClass = (mode) => {
503
+ clearCbClasses();
504
+ const cls = MODE_TO_CLASS[mode];
505
+ if (cls) document.documentElement.classList.add(cls);
506
+ };
507
+ const currentCbMode = () => {
508
+ const rootEl = document.documentElement;
509
+ for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) {
510
+ if (rootEl.classList.contains(cls)) return mode;
511
+ }
512
+ return "none";
513
+ };
514
+ const setupCbSim = () => {
515
+ const select = document.getElementById("cb-select");
516
+ if (!select) return;
517
+ try {
518
+ select.value = currentCbMode();
519
+ } catch {}
520
+ select.addEventListener("change", () => applyCbClass(select.value));
521
+ };
522
+ const setupCountControl = () => {
523
+ const input = document.getElementById("color-count");
524
+ const out = document.getElementById("color-count-out");
525
+ if (!input) return;
526
+ const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
527
+ const read = () => clamp(Number(input.value) || 6, 6, 10);
528
+ const syncOut = () => {
529
+ if (out) out.textContent = String(read());
530
+ };
531
+ const onChange = () => {
532
+ syncOut();
533
+ render();
534
+ };
535
+ syncOut();
536
+ input.addEventListener("input", onChange);
537
+ document.addEventListener("palettes:updated", () => {
538
+ syncOut();
539
+ render();
540
+ });
541
+ };
542
+ let copyDelegationSetup = false;
543
+ const setupCopyDelegation = () => {
544
+ if (copyDelegationSetup) return;
545
+ const grid = document.querySelector(".palettes .palettes__grid");
546
+ if (!grid) return;
547
+ grid.addEventListener("click", async (e) => {
548
+ const btn = e.target.closest
549
+ ? e.target.closest(".palette-card__copy")
550
+ : null;
551
+ if (!btn) return;
552
+ const card = btn.closest(".palette-card");
553
+ if (!card) return;
554
+ const colors = (card.dataset.colors || "").split(",").filter(Boolean);
555
+ const json = JSON.stringify(colors, null, 2);
556
+ try {
557
+ await navigator.clipboard.writeText(json);
558
+ const old = btn.innerHTML;
559
+ btn.innerHTML =
560
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>';
561
+ setTimeout(() => (btn.innerHTML = old), 900);
562
+ } catch {
563
+ window.prompt("Copy palette", json);
564
+ }
565
+ });
566
+ copyDelegationSetup = true;
567
+ };
568
+ const bootstrap = () => {
569
+ setupCbSim();
570
+ setupCountControl();
571
+ setupCopyDelegation();
572
+ // Render immediately
573
+ render();
574
+ // Re-render on palette updates
575
+ document.addEventListener("palettes:updated", render);
576
+ // Force an immediate notify after listeners are attached (ensures initial render)
577
+ try {
578
+ if (
579
+ window.ColorPalettes &&
580
+ typeof window.ColorPalettes.notify === "function"
581
+ )
582
+ window.ColorPalettes.notify();
583
+ else if (
584
+ window.ColorPalettes &&
585
+ typeof window.ColorPalettes.refresh === "function"
586
+ )
587
+ window.ColorPalettes.refresh();
588
+ } catch {}
589
+ };
590
+ if (document.readyState === "loading") {
591
+ document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
592
+ } else {
593
+ bootstrap();
594
+ }
595
+ })();
596
+ </script>
app/src/components/trackio/Trackio.svelte CHANGED
@@ -1,14 +1,20 @@
1
  <script>
2
- import * as d3 from 'd3';
3
- import { formatAbbrev, smoothMetricData } from './core/chart-utils.js';
4
- import { generateRunNames, genCurves, Random, Performance, generateMassiveTestDataset } from './core/data-generator.js';
5
- import Legend from './components/Legend.svelte';
6
- import Cell from './components/Cell.svelte';
7
- import FullscreenModal from './components/FullscreenModal.svelte';
8
- import { onMount, onDestroy } from 'svelte';
9
- import { jitterTrigger } from './core/store.js';
10
-
11
- export let variant = 'classic'; // 'classic' | 'oblivion'
 
 
 
 
 
 
12
  export let normalizeLoss = true;
13
  export let logScaleX = false;
14
  export let smoothing = false;
@@ -17,99 +23,143 @@
17
  let gridEl;
18
  let legendItems = [];
19
  const cellsDef = [
20
- { metric:'epoch', title:'Epoch' },
21
- { metric:'train_accuracy', title:'Train accuracy' },
22
- { metric:'train_loss', title:'Train loss' },
23
- { metric:'val_accuracy', title:'Val accuracy' },
24
- { metric:'val_loss', title:'Val loss', wide:true }
25
  ];
26
  let preparedData = {};
27
  let colorsByRun = {};
28
-
29
  // Variables for data management (will be initialized in onMount)
30
  let dataByMetric = new Map();
31
  let metricsToDraw = [];
32
  let currentRunList = [];
33
  let cycleIdx = 2;
34
-
35
  // Dynamic color palette using color-palettes.js helper
36
- let dynamicPalette = ['#0ea5e9', '#8b5cf6', '#f59e0b', '#ef4444', '#10b981', '#f97316', '#3b82f6', '#8b5ad6']; // fallback
37
-
 
 
 
 
 
 
 
 
 
38
  const updateDynamicPalette = () => {
39
- if (typeof window !== 'undefined' && window.ColorPalettes && currentRunList.length > 0) {
 
 
 
 
40
  try {
41
- dynamicPalette = window.ColorPalettes.getColors('categorical', currentRunList.length);
 
 
 
42
  } catch (e) {
43
- console.warn('Failed to generate dynamic palette:', e);
44
  // Keep fallback palette
45
  }
46
  }
47
  };
48
-
49
  const colorForRun = (name) => {
50
  const idx = currentRunList.indexOf(name);
51
- return idx >= 0 ? dynamicPalette[idx % dynamicPalette.length] : '#999';
52
  };
53
-
54
 
55
  // Jitter function - generates completely new data with new runs
56
- function jitterData(){
57
- console.log('jitterData called - generating new data with random number of runs'); // Debug log
58
-
 
 
59
  // Generate new random data with weighted probability for fewer runs
60
  // Higher probability for 2-3 runs, lower for 4-5-6 runs
61
  const rand = Math.random();
62
  let wantRuns;
63
- if (rand < 0.4) wantRuns = 2; // 40% chance
64
- else if (rand < 0.7) wantRuns = 3; // 30% chance
65
- else if (rand < 0.85) wantRuns = 4; // 15% chance
66
- else if (rand < 0.95) wantRuns = 5; // 10% chance
67
- else wantRuns = 6; // 5% chance
 
 
 
 
68
  // Use realistic ML training step counts
69
  const stepsCount = Random.trainingSteps();
70
  const runsSim = generateRunNames(wantRuns, stepsCount);
71
- const steps = Array.from({length: stepsCount}, (_,i)=> i+1);
72
  const nextByMetric = new Map();
73
- const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
74
-
 
 
 
 
 
 
75
  // Initialize data structure
76
  TARGET_METRICS.forEach((tgt) => {
77
  const map = {};
78
- runsSim.forEach((r) => { map[r] = []; });
 
 
79
  nextByMetric.set(tgt, map);
80
  });
81
-
82
  // Generate curves for each run
83
- runsSim.forEach(run => {
84
  const curves = genCurves(stepsCount);
85
- steps.forEach((s,i)=>{
86
- nextByMetric.get('epoch')[run].push({ step:s, value:s });
87
- nextByMetric.get('train_accuracy')[run].push({ step:s, value: curves.accTrain[i] });
88
- nextByMetric.get('val_accuracy')[run].push({ step:s, value: curves.accVal[i] });
89
- nextByMetric.get('train_loss')[run].push({ step:s, value: curves.lossTrain[i] });
90
- nextByMetric.get('val_loss')[run].push({ step:s, value: curves.lossVal[i] });
 
 
 
 
 
 
 
 
91
  });
92
  });
93
-
94
  // Update all reactive data
95
- nextByMetric.forEach((v,k)=> dataByMetric.set(k,v));
96
  metricsToDraw = TARGET_METRICS;
97
  currentRunList = runsSim.slice();
98
  updateDynamicPalette(); // Generate new colors based on run count
99
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
 
 
 
100
  updatePreparedData();
101
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
102
-
103
- console.log(`jitterData completed - generated ${wantRuns} runs with ${stepsCount} steps`); // Debug log
 
 
 
 
104
  }
105
 
106
  // Public API: allow external theme switch
107
- function setTheme(name){
108
- variant = name === 'oblivion' ? 'oblivion' : 'classic';
109
  updateThemeClass();
110
-
111
  // Debug log for font application
112
- if (typeof window !== 'undefined') {
113
  console.log(`Theme switched to: ${variant}`);
114
  if (hostEl) {
115
  const computedStyle = getComputedStyle(hostEl);
@@ -135,41 +185,67 @@
135
 
136
  // Public API: generate massive test dataset
137
  function generateMassiveDataset(steps = null, runs = 3) {
138
- console.log('🧪 Generating massive test dataset for sampling validation...');
139
-
 
 
140
  const result = generateMassiveTestDataset(steps, runs);
141
-
142
  // Update reactive data with massive dataset
143
  result.dataByMetric.forEach((v, k) => dataByMetric.set(k, v));
144
- metricsToDraw = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
 
 
 
 
 
 
145
  currentRunList = result.runNames.slice();
146
  updateDynamicPalette();
147
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
 
 
 
148
  updatePreparedData();
149
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
150
-
151
- console.log(`✅ Massive dataset loaded: ${result.stepCount} steps × ${result.runNames.length} runs`);
 
 
 
 
152
  console.log(`📊 Total data points: ${result.totalPoints.toLocaleString()}`);
153
  console.log(`🎯 Description: ${result.description}`);
154
-
155
  return result;
156
  }
157
 
158
  // Public API: add live data point for simulation
159
  function addLiveDataPoint(runName, dataPoint) {
160
  console.log(`Adding live data point for run "${runName}":`, dataPoint);
161
-
162
  // Add run to currentRunList if it doesn't exist
163
  if (!currentRunList.includes(runName)) {
164
  currentRunList = [...currentRunList, runName];
165
  updateDynamicPalette();
166
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
167
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
 
 
 
 
 
168
  }
169
-
170
  // Initialize data structures for the run if needed
171
- const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
172
- TARGET_METRICS.forEach(metric => {
 
 
 
 
 
 
173
  if (!dataByMetric.has(metric)) {
174
  dataByMetric.set(metric, {});
175
  }
@@ -178,114 +254,125 @@
178
  metricData[runName] = [];
179
  }
180
  });
181
-
182
  // Add the new data points to each metric
183
  const step = dataPoint.step;
184
-
185
  // Add epoch data
186
- const epochData = dataByMetric.get('epoch');
187
  epochData[runName].push({ step, value: step });
188
-
189
  // Add accuracy data (train and val get the same value for simplicity)
190
  if (dataPoint.accuracy !== undefined) {
191
- const trainAccData = dataByMetric.get('train_accuracy');
192
- const valAccData = dataByMetric.get('val_accuracy');
193
-
194
  // Add some noise between train and val accuracy
195
  const trainAcc = dataPoint.accuracy;
196
- const valAcc = Math.max(0, Math.min(1, dataPoint.accuracy - 0.01 - Math.random() * 0.03));
197
-
 
 
 
198
  trainAccData[runName].push({ step, value: trainAcc });
199
  valAccData[runName].push({ step, value: valAcc });
200
  }
201
-
202
  // Add loss data (train and val get the same value for simplicity)
203
  if (dataPoint.loss !== undefined) {
204
- const trainLossData = dataByMetric.get('train_loss');
205
- const valLossData = dataByMetric.get('val_loss');
206
-
207
  // Add some noise between train and val loss
208
  const trainLoss = dataPoint.loss;
209
  const valLoss = dataPoint.loss + 0.05 + Math.random() * 0.1;
210
-
211
  trainLossData[runName].push({ step, value: trainLoss });
212
  valLossData[runName].push({ step, value: valLoss });
213
  }
214
-
215
  // Update all metrics to draw
216
  metricsToDraw = TARGET_METRICS;
217
-
218
  // Update prepared data with new values
219
  updatePreparedData();
220
-
221
- console.log(`Live data point added successfully. Total runs: ${currentRunList.length}`);
 
 
222
  }
223
 
224
  // Update prepared data with optional smoothing
225
  let preparedRawData = {}; // Store original data for background display
226
-
227
  function updatePreparedData() {
228
- const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
 
 
 
 
 
 
229
  let dataToUse = {};
230
  let rawDataToStore = {};
231
-
232
- TARGET_METRICS.forEach(metric => {
233
  const rawData = dataByMetric.get(metric);
234
  if (rawData) {
235
  // Store original data
236
  rawDataToStore[metric] = rawData;
237
-
238
  // Apply smoothing if enabled (except for epoch which should stay exact)
239
- dataToUse[metric] = (smoothing && metric !== 'epoch')
240
- ? smoothMetricData(rawData, 5) // Window size of 5
241
- : rawData;
 
242
  }
243
  });
244
-
245
  preparedData = dataToUse;
246
  preparedRawData = rawDataToStore;
247
  console.log(`Prepared data updated, smoothing: ${smoothing}`);
248
  }
249
 
250
- function updateThemeClass(){
251
  if (!hostEl) return;
252
- hostEl.classList.toggle('theme--classic', variant === 'classic');
253
- hostEl.classList.toggle('theme--oblivion', variant === 'oblivion');
254
- hostEl.setAttribute('data-variant', variant);
255
  }
256
 
257
  $: updateThemeClass();
258
 
259
-
260
  // Chart logic now handled by Cell.svelte
261
-
262
  // Fullscreen navigation state
263
  let currentFullscreenIndex = 0;
264
  let isModalOpen = false;
265
-
266
  function handleNavigate(newIndex) {
267
  currentFullscreenIndex = newIndex;
268
  }
269
-
270
  function openModal(index) {
271
  currentFullscreenIndex = index;
272
  isModalOpen = true;
273
  }
274
-
275
  function closeModal() {
276
  isModalOpen = false;
277
  }
278
-
279
  // Prepare all charts data for navigation
280
- $: allChartsData = cellsDef.map(c => ({
281
  metricKey: c.metric,
282
  titleText: c.title,
283
  metricData: (preparedData && preparedData[c.metric]) || {},
284
- rawMetricData: (preparedRawData && preparedRawData[c.metric]) || {}
285
  }));
286
-
287
  // Color function for the modal
288
- $: modalColorForRun = (name) => colorsByRun[name] || '#999';
289
 
290
  let cleanup = null;
291
  onMount(() => {
@@ -293,66 +380,97 @@
293
  hostEl.__setTheme = setTheme;
294
 
295
  // Jitter & Simulate functions
296
- function rebuildLegend(){
297
  updateDynamicPalette(); // Update colors when adding new data
298
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
 
 
 
299
  }
300
-
301
- function simulateData(){
302
  // Generate new random data with weighted probability for fewer runs
303
  // Higher probability for 2-3 runs, lower for 4-5-6 runs
304
  const rand = Math.random();
305
  let wantRuns;
306
- if (rand < 0.4) wantRuns = 2; // 40% chance
307
- else if (rand < 0.7) wantRuns = 3; // 30% chance
308
- else if (rand < 0.85) wantRuns = 4; // 15% chance
309
- else if (rand < 0.95) wantRuns = 5; // 10% chance
310
- else wantRuns = 6; // 5% chance
 
 
 
 
311
  // Use realistic ML training step counts with cycling scenarios
312
  let stepsCount;
313
  if (cycleIdx === 0) {
314
- stepsCount = Random.trainingStepsForScenario('prototyping');
315
  } else if (cycleIdx === 1) {
316
- stepsCount = Random.trainingStepsForScenario('development');
317
  } else if (cycleIdx === 2) {
318
- stepsCount = Random.trainingStepsForScenario('production');
319
  } else if (cycleIdx === 3) {
320
- stepsCount = Random.trainingStepsForScenario('research');
321
  } else if (cycleIdx === 4) {
322
- stepsCount = Random.trainingStepsForScenario('llm');
323
  } else if (cycleIdx === 5) {
324
- stepsCount = Random.trainingStepsForScenario('massive');
325
  } else {
326
  stepsCount = Random.trainingSteps(); // Full range for variety
327
  }
328
  cycleIdx = (cycleIdx + 1) % 7; // Cycle through 7 scenarios now
329
-
330
  const runsSim = generateRunNames(wantRuns, stepsCount);
331
- const steps = Array.from({length: stepsCount}, (_,i)=> i+1);
332
  const nextByMetric = new Map();
333
- const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
334
- const mList = (metricsToDraw && metricsToDraw.length) ? metricsToDraw : TARGET_METRICS;
 
 
 
 
 
 
 
335
  mList.forEach((tgt) => {
336
  const map = {};
337
- runsSim.forEach((r) => { map[r] = []; });
 
 
338
  nextByMetric.set(tgt, map);
339
  });
340
- runsSim.forEach(run => {
341
  const curves = genCurves(stepsCount);
342
- steps.forEach((s,i)=>{
343
- if (mList.includes('epoch')) nextByMetric.get('epoch')[run].push({ step:s, value:s });
344
- if (mList.includes('train_accuracy')) nextByMetric.get('train_accuracy')[run].push({ step:s, value: curves.accTrain[i] });
345
- if (mList.includes('val_accuracy')) nextByMetric.get('val_accuracy')[run].push({ step:s, value: curves.accVal[i] });
346
- if (mList.includes('train_loss')) nextByMetric.get('train_loss')[run].push({ step:s, value: curves.lossTrain[i] });
347
- if (mList.includes('val_loss')) nextByMetric.get('val_loss')[run].push({ step:s, value: curves.lossVal[i] });
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  });
349
  });
350
- nextByMetric.forEach((v,k)=> dataByMetric.set(k,v));
351
  currentRunList = runsSim.slice();
352
  rebuildLegend();
353
  updatePreparedData();
354
  updateDynamicPalette(); // Update colors when rebuilding
355
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
 
 
356
  }
357
  // No need for event listeners anymore - we'll use reactive statement
358
 
@@ -360,105 +478,158 @@
360
  simulateData();
361
  // Svelte Cells will react to preparedData/colorsByRun updates
362
 
363
- cleanup = () => {
364
  // No cleanup needed for reactive statements
365
  };
366
  });
367
 
368
- onDestroy(() => { if (cleanup) cleanup(); });
 
 
369
 
370
  // Expose instance for debugging and external theme control
371
  onMount(() => {
372
- window.trackioInstance = { jitterData, addLiveDataPoint, generateMassiveDataset };
 
 
 
 
373
  if (hostEl) {
374
- hostEl.__trackioInstance = { setTheme, setLogScaleX, setSmoothing, jitterData, addLiveDataPoint, generateMassiveDataset };
 
 
 
 
 
 
 
375
  }
376
-
377
  // Initialize dynamic palette
378
  updateDynamicPalette();
379
-
380
  // Listen for palette updates from color-palettes.js
381
  const handlePaletteUpdate = () => {
382
  updateDynamicPalette();
383
  // Rebuild legend and colors if needed
384
  if (currentRunList.length > 0) {
385
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
386
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
 
 
 
 
 
387
  }
388
  };
389
-
390
- document.addEventListener('palettes:updated', handlePaletteUpdate);
391
-
392
  // Cleanup listener on destroy
393
  return () => {
394
- document.removeEventListener('palettes:updated', handlePaletteUpdate);
395
  };
396
  });
397
 
398
  // React to jitter trigger from store
399
  $: {
400
- console.log('Reactive statement triggered, jitterTrigger value:', $jitterTrigger);
 
 
 
401
  if ($jitterTrigger > 0) {
402
- console.log('Jitter trigger activated:', $jitterTrigger, 'calling jitterData()');
 
 
 
 
403
  jitterData();
404
  }
405
  }
406
 
407
  // Legend ghost helpers (hover effects)
408
- function ghostRun(run){
409
  try {
410
- hostEl.classList.add('hovering');
411
-
412
  // Ghost the chart lines and points
413
- hostEl.querySelectorAll('.cell').forEach(cell => {
414
- cell.querySelectorAll('svg .lines path.run-line').forEach(p => p.classList.toggle('ghost', p.getAttribute('data-run') !== run));
415
- cell.querySelectorAll('svg .lines path.raw-line').forEach(p => p.classList.toggle('ghost', p.getAttribute('data-run') !== run));
416
- cell.querySelectorAll('svg .points circle.pt').forEach(c => c.classList.toggle('ghost', c.getAttribute('data-run') !== run));
 
 
 
 
 
 
 
 
 
 
 
 
417
  });
418
-
419
  // Ghost the legend items
420
- hostEl.querySelectorAll('.legend-bottom .item').forEach(item => {
421
- const itemRun = item.getAttribute('data-run');
422
- item.classList.toggle('ghost', itemRun !== run);
423
  });
424
- } catch(_) {}
425
  }
426
- function clearGhost(){
427
  try {
428
- hostEl.classList.remove('hovering');
429
-
430
  // Clear ghost from chart lines and points
431
- hostEl.querySelectorAll('.cell').forEach(cell => {
432
- cell.querySelectorAll('svg .lines path.run-line').forEach(p => p.classList.remove('ghost'));
433
- cell.querySelectorAll('svg .lines path.raw-line').forEach(p => p.classList.remove('ghost'));
434
- cell.querySelectorAll('svg .points circle.pt').forEach(c => c.classList.remove('ghost'));
 
 
 
 
 
 
435
  });
436
-
437
  // Clear ghost from legend items
438
- hostEl.querySelectorAll('.legend-bottom .item').forEach(item => {
439
- item.classList.remove('ghost');
440
  });
441
- } catch(_) {}
442
  }
443
  </script>
444
 
445
  <div class="trackio theme--classic" bind:this={hostEl} data-variant={variant}>
446
  <div class="trackio__header">
447
- <Legend items={legendItems} on:legend-hover={(e) => { const run = e?.detail?.name; if (!run) return; ghostRun(run); }} on:legend-leave={() => { clearGhost(); }} />
 
 
 
 
 
 
 
 
 
 
448
  </div>
449
  <div class="trackio__grid" bind:this={gridEl}>
450
  {#each cellsDef as c, i}
451
- <Cell
452
- metricKey={c.metric}
453
- titleText={c.title}
454
- wide={c.wide}
455
- {variant}
456
- {normalizeLoss}
457
- {logScaleX}
458
- {smoothing}
459
- metricData={(preparedData && preparedData[c.metric]) || {}}
460
- rawMetricData={(preparedRawData && preparedRawData[c.metric]) || {}}
461
- colorForRun={(name)=> colorsByRun[name] || '#999'}
462
  {hostEl}
463
  currentIndex={i}
464
  onOpenModal={openModal}
@@ -467,9 +638,17 @@
467
  </div>
468
  <div class="trackio__footer">
469
  <small>
470
- Built with <a href="https://github.com/huggingface/trackio" target="_blank" rel="noopener noreferrer">TrackIO</a>
 
 
 
 
471
  <span class="separator">•</span>
472
- <a href="https://huggingface.co/docs/hub/spaces-sdks-docker" target="_blank" rel="noopener noreferrer">Use via API</a>
 
 
 
 
473
  </small>
474
  </div>
475
  </div>
@@ -477,7 +656,7 @@
477
  <!-- Centralized Fullscreen Modal -->
478
  <FullscreenModal
479
  visible={isModalOpen}
480
- title={allChartsData[currentFullscreenIndex]?.titleText || ''}
481
  metricData={allChartsData[currentFullscreenIndex]?.metricData || {}}
482
  rawMetricData={allChartsData[currentFullscreenIndex]?.rawMetricData || {}}
483
  colorForRun={modalColorForRun}
@@ -485,8 +664,8 @@
485
  {logScaleX}
486
  {smoothing}
487
  {normalizeLoss}
488
- metricKey={allChartsData[currentFullscreenIndex]?.metricKey || ''}
489
- titleText={allChartsData[currentFullscreenIndex]?.titleText || ''}
490
  currentIndex={currentFullscreenIndex}
491
  totalCharts={cellsDef.length}
492
  onNavigate={handleNavigate}
@@ -499,12 +678,13 @@
499
  ========================= */
500
 
501
  /* Font imports for themes - ensure Roboto Mono is loaded for Oblivion theme */
502
- @import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600;700&display=swap');
503
-
504
  /* Fallback font-face declaration */
505
  @font-face {
506
- font-family: 'Roboto Mono Fallback';
507
- src: url('https://fonts.gstatic.com/s/robotomono/v23/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW4AJi8SJQt.woff2') format('woff2');
 
508
  font-weight: 400;
509
  font-style: normal;
510
  font-display: swap;
@@ -515,31 +695,37 @@
515
  position: relative;
516
  --z-tooltip: 50;
517
  --z-overlay: 99999999;
518
-
519
  /* Typography */
520
- --trackio-font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
 
 
 
 
 
 
521
  --trackio-font-weight-normal: 400;
522
  --trackio-font-weight-medium: 600;
523
  --trackio-font-weight-bold: 700;
524
-
525
  /* Apply font-family to root element */
526
  font-family: var(--trackio-font-family);
527
-
528
  /* Base color system for Classic theme */
529
  --trackio-base: #323232;
530
  --trackio-primary: var(--trackio-base);
531
  --trackio-dim: color-mix(in srgb, var(--trackio-base) 28%, transparent);
532
  --trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
533
  --trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
534
-
535
  /* Chart rendering */
536
- --trackio-chart-grid-type: 'lines'; /* 'lines' | 'dots' */
537
  --trackio-chart-axis-stroke: var(--trackio-dim);
538
  --trackio-chart-axis-text: var(--trackio-text);
539
  --trackio-chart-grid-stroke: var(--trackio-subtle);
540
  --trackio-chart-grid-opacity: 1;
541
  }
542
-
543
  /* Dark mode overrides for Classic theme */
544
  :global([data-theme="dark"]) .trackio.theme--classic {
545
  --trackio-base: #ffffff;
@@ -547,28 +733,28 @@
547
  --trackio-dim: color-mix(in srgb, var(--trackio-base) 25%, transparent);
548
  --trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
549
  --trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
550
-
551
  /* Cell background for dark mode */
552
  --trackio-cell-background: rgba(255, 255, 255, 0.03);
553
  }
554
-
555
  .trackio.theme--classic {
556
  /* Cell styling */
557
  --trackio-cell-background: rgba(0, 0, 0, 0.02);
558
  --trackio-cell-border: var(--border-color, rgba(0, 0, 0, 0.1));
559
  --trackio-cell-corner-inset: 0px;
560
  --trackio-cell-gap: 12px;
561
-
562
  /* Typography */
563
  --trackio-text-primary: var(--text-color, rgba(0, 0, 0, 0.9));
564
  --trackio-text-secondary: var(--muted-color, rgba(0, 0, 0, 0.6));
565
- --trackio-text-accent: var(--primary-color, #E889AB);
566
-
567
  /* Tooltip */
568
  --trackio-tooltip-background: var(--surface-bg, white);
569
  --trackio-tooltip-border: var(--border-color, rgba(0, 0, 0, 0.1));
570
  --trackio-tooltip-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
571
-
572
  /* Legend */
573
  --trackio-legend-text: var(--text-color, rgba(0, 0, 0, 0.9));
574
  --trackio-legend-swatch-border: var(--border-color, rgba(0, 0, 0, 0.1));
@@ -577,14 +763,14 @@
577
  /* Dark mode adjustments */
578
  :global([data-theme="dark"]) .trackio {
579
  --trackio-chart-axis-stroke: rgba(255, 255, 255, 0.18);
580
- --trackio-chart-axis-text: rgba(255, 255, 255, 0.60);
581
  --trackio-chart-grid-stroke: rgba(255, 255, 255, 0.08);
582
  }
583
 
584
  /* =========================
585
  THEME: CLASSIC (Default)
586
  ========================= */
587
-
588
  .trackio.theme--classic {
589
  /* Keep default values - no overrides needed */
590
  }
@@ -592,64 +778,96 @@
592
  /* =========================
593
  THEME: OBLIVION
594
  ========================= */
595
-
596
  .trackio.theme--oblivion {
597
  /* Core oblivion color system - Light mode: darker colors for visibility */
598
  --trackio-oblivion-base: #2a2a2a;
599
  --trackio-oblivion-primary: var(--trackio-oblivion-base);
600
- --trackio-oblivion-dim: color-mix(in srgb, var(--trackio-oblivion-base) 30%, transparent);
601
- --trackio-oblivion-subtle: color-mix(in srgb, var(--trackio-oblivion-base) 8%, transparent);
602
- --trackio-oblivion-ghost: color-mix(in srgb, var(--trackio-oblivion-base) 4%, transparent);
603
-
 
 
 
 
 
 
 
 
 
 
 
 
604
  /* Chart rendering overrides */
605
- --trackio-chart-grid-type: 'dots';
606
  --trackio-chart-axis-stroke: var(--trackio-oblivion-dim);
607
  --trackio-chart-axis-text: var(--trackio-oblivion-primary);
608
  --trackio-chart-grid-stroke: var(--trackio-oblivion-dim);
609
  --trackio-chart-grid-opacity: 0.6;
610
  }
611
-
612
  /* Dark mode overrides for Oblivion theme */
613
  :global([data-theme="dark"]) .trackio.theme--oblivion {
614
  --trackio-oblivion-base: #ffffff;
615
  --trackio-oblivion-primary: var(--trackio-oblivion-base);
616
- --trackio-oblivion-dim: color-mix(in srgb, var(--trackio-oblivion-base) 25%, transparent);
617
- --trackio-oblivion-subtle: color-mix(in srgb, var(--trackio-oblivion-base) 8%, transparent);
618
- --trackio-oblivion-ghost: color-mix(in srgb, var(--trackio-oblivion-base) 4%, transparent);
 
 
 
 
 
 
 
 
 
 
 
 
619
  }
620
-
621
  .trackio.theme--oblivion {
622
  /* Cell styling overrides */
623
  --trackio-cell-background: var(--trackio-oblivion-subtle);
624
  --trackio-cell-border: var(--trackio-oblivion-dim);
625
  --trackio-cell-corner-inset: 6px;
626
  --trackio-cell-gap: 0px;
627
-
628
  /* HUD-specific variables */
629
  --trackio-oblivion-hud-gap: 10px;
630
  --trackio-oblivion-hud-corner-size: 8px;
631
- --trackio-oblivion-hud-bg-gradient:
632
- radial-gradient(1200px 200px at 20% -10%, var(--trackio-oblivion-ghost), transparent 80%),
633
- radial-gradient(900px 200px at 80% 110%, var(--trackio-oblivion-ghost), transparent 80%);
634
-
 
 
 
 
 
 
 
635
  /* Typography overrides */
636
  --trackio-text-primary: var(--trackio-oblivion-primary);
637
  --trackio-text-secondary: var(--trackio-oblivion-dim);
638
  --trackio-text-accent: var(--trackio-oblivion-primary);
639
-
640
  /* Tooltip overrides */
641
  --trackio-tooltip-background: var(--trackio-oblivion-subtle);
642
  --trackio-tooltip-border: var(--trackio-oblivion-dim);
643
- --trackio-tooltip-shadow:
644
- 0 8px 32px color-mix(in srgb, var(--trackio-oblivion-base) 8%, transparent),
645
  0 2px 8px color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent);
646
-
647
  /* Legend overrides */
648
  --trackio-legend-text: var(--trackio-oblivion-primary);
649
  --trackio-legend-swatch-border: var(--trackio-oblivion-dim);
650
-
651
  /* Font styling overrides */
652
- --trackio-font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace;
 
653
  font-family: var(--trackio-font-family) !important;
654
  color: var(--trackio-text-primary);
655
  }
@@ -657,29 +875,42 @@
657
  /* Force Roboto Mono application in Oblivion theme */
658
  .trackio.theme--oblivion,
659
  .trackio.theme--oblivion * {
660
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
661
  }
662
-
663
  /* Specific overrides for different elements in Oblivion */
664
  .trackio.theme--oblivion .cell-title,
665
  .trackio.theme--oblivion .legend-bottom,
666
  .trackio.theme--oblivion .legend-title,
667
  .trackio.theme--oblivion .item {
668
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
669
  }
670
 
671
  /* Dark mode adjustments for Oblivion */
672
  :global([data-theme="dark"]) .trackio.theme--oblivion {
673
  --trackio-oblivion-base: #ffffff;
674
- --trackio-oblivion-hud-bg-gradient:
675
- radial-gradient(1400px 260px at 20% -10%, color-mix(in srgb, var(--trackio-oblivion-base) 6.5%, transparent), transparent 80%),
676
- radial-gradient(1100px 240px at 80% 110%, color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent), transparent 80%),
677
- linear-gradient(180deg, color-mix(in srgb, var(--trackio-oblivion-base) 3.5%, transparent), transparent 45%);
678
-
679
- --trackio-tooltip-shadow:
680
- 0 8px 32px color-mix(in srgb, var(--trackio-oblivion-base) 5%, transparent),
 
 
 
 
 
 
 
 
 
 
 
681
  0 2px 8px color-mix(in srgb, black 10%, transparent);
682
-
683
  background: #0f1115;
684
  }
685
 
@@ -692,9 +923,11 @@
692
  grid-template-columns: repeat(2, minmax(0, 1fr));
693
  gap: var(--trackio-cell-gap);
694
  }
695
-
696
  @media (max-width: 980px) {
697
- .trackio__grid { grid-template-columns: 1fr; }
 
 
698
  }
699
 
700
  .trackio__header {
@@ -712,28 +945,37 @@
712
  .trackio .axes line {
713
  stroke: var(--trackio-chart-axis-stroke);
714
  }
715
-
716
  .trackio .axes text {
717
  fill: var(--trackio-chart-axis-text);
718
  font-family: var(--trackio-font-family);
719
  }
720
-
721
  /* Force font-family for SVG text in Oblivion */
722
  .trackio.theme--oblivion .axes text {
723
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
724
  }
725
-
726
  .trackio .grid line {
727
  stroke: var(--trackio-chart-grid-stroke);
728
  opacity: var(--trackio-chart-grid-opacity);
729
  }
730
 
731
  /* Grid type switching */
732
- .trackio .grid-dots { display: none; }
733
- .trackio.theme--oblivion .grid { display: none; }
734
- .trackio.theme--oblivion .grid-dots { display: block; }
 
 
 
 
 
 
735
  .trackio.theme--oblivion .cell-bg,
736
- .trackio.theme--oblivion .cell-corners { display: block; }
 
 
737
 
738
  /* =========================
739
  FOOTER
@@ -777,8 +1019,7 @@
777
  }
778
 
779
  .trackio.theme--oblivion .trackio__footer small {
780
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
781
  }
782
  </style>
783
-
784
-
 
1
  <script>
2
+ import * as d3 from "d3";
3
+ import { formatAbbrev, smoothMetricData } from "./core/chart-utils.js";
4
+ import {
5
+ generateRunNames,
6
+ genCurves,
7
+ Random,
8
+ Performance,
9
+ generateMassiveTestDataset,
10
+ } from "./core/data-generator.js";
11
+ import Legend from "./components/Legend.svelte";
12
+ import Cell from "./components/Cell.svelte";
13
+ import FullscreenModal from "./components/FullscreenModal.svelte";
14
+ import { onMount, onDestroy } from "svelte";
15
+ import { jitterTrigger } from "./core/store.js";
16
+
17
+ export let variant = "classic"; // 'classic' | 'oblivion'
18
  export let normalizeLoss = true;
19
  export let logScaleX = false;
20
  export let smoothing = false;
 
23
  let gridEl;
24
  let legendItems = [];
25
  const cellsDef = [
26
+ { metric: "epoch", title: "Epoch" },
27
+ { metric: "train_accuracy", title: "Train accuracy" },
28
+ { metric: "train_loss", title: "Train loss" },
29
+ { metric: "val_accuracy", title: "Val accuracy" },
30
+ { metric: "val_loss", title: "Val loss", wide: true },
31
  ];
32
  let preparedData = {};
33
  let colorsByRun = {};
34
+
35
  // Variables for data management (will be initialized in onMount)
36
  let dataByMetric = new Map();
37
  let metricsToDraw = [];
38
  let currentRunList = [];
39
  let cycleIdx = 2;
40
+
41
  // Dynamic color palette using color-palettes.js helper
42
+ let dynamicPalette = [
43
+ "#0ea5e9",
44
+ "#8b5cf6",
45
+ "#f59e0b",
46
+ "#ef4444",
47
+ "#10b981",
48
+ "#f97316",
49
+ "#3b82f6",
50
+ "#8b5ad6",
51
+ ]; // fallback
52
+
53
  const updateDynamicPalette = () => {
54
+ if (
55
+ typeof window !== "undefined" &&
56
+ window.ColorPalettes &&
57
+ currentRunList.length > 0
58
+ ) {
59
  try {
60
+ dynamicPalette = window.ColorPalettes.getColors(
61
+ "categorical",
62
+ currentRunList.length,
63
+ );
64
  } catch (e) {
65
+ console.warn("Failed to generate dynamic palette:", e);
66
  // Keep fallback palette
67
  }
68
  }
69
  };
70
+
71
  const colorForRun = (name) => {
72
  const idx = currentRunList.indexOf(name);
73
+ return idx >= 0 ? dynamicPalette[idx % dynamicPalette.length] : "#999";
74
  };
 
75
 
76
  // Jitter function - generates completely new data with new runs
77
+ function jitterData() {
78
+ console.log(
79
+ "jitterData called - generating new data with random number of runs",
80
+ ); // Debug log
81
+
82
  // Generate new random data with weighted probability for fewer runs
83
  // Higher probability for 2-3 runs, lower for 4-5-6 runs
84
  const rand = Math.random();
85
  let wantRuns;
86
+ if (rand < 0.4)
87
+ wantRuns = 2; // 40% chance
88
+ else if (rand < 0.7)
89
+ wantRuns = 3; // 30% chance
90
+ else if (rand < 0.85)
91
+ wantRuns = 4; // 15% chance
92
+ else if (rand < 0.95)
93
+ wantRuns = 5; // 10% chance
94
+ else wantRuns = 6; // 5% chance
95
  // Use realistic ML training step counts
96
  const stepsCount = Random.trainingSteps();
97
  const runsSim = generateRunNames(wantRuns, stepsCount);
98
+ const steps = Array.from({ length: stepsCount }, (_, i) => i + 1);
99
  const nextByMetric = new Map();
100
+ const TARGET_METRICS = [
101
+ "epoch",
102
+ "train_accuracy",
103
+ "train_loss",
104
+ "val_accuracy",
105
+ "val_loss",
106
+ ];
107
+
108
  // Initialize data structure
109
  TARGET_METRICS.forEach((tgt) => {
110
  const map = {};
111
+ runsSim.forEach((r) => {
112
+ map[r] = [];
113
+ });
114
  nextByMetric.set(tgt, map);
115
  });
116
+
117
  // Generate curves for each run
118
+ runsSim.forEach((run) => {
119
  const curves = genCurves(stepsCount);
120
+ steps.forEach((s, i) => {
121
+ nextByMetric.get("epoch")[run].push({ step: s, value: s });
122
+ nextByMetric
123
+ .get("train_accuracy")
124
+ [run].push({ step: s, value: curves.accTrain[i] });
125
+ nextByMetric
126
+ .get("val_accuracy")
127
+ [run].push({ step: s, value: curves.accVal[i] });
128
+ nextByMetric
129
+ .get("train_loss")
130
+ [run].push({ step: s, value: curves.lossTrain[i] });
131
+ nextByMetric
132
+ .get("val_loss")
133
+ [run].push({ step: s, value: curves.lossVal[i] });
134
  });
135
  });
136
+
137
  // Update all reactive data
138
+ nextByMetric.forEach((v, k) => dataByMetric.set(k, v));
139
  metricsToDraw = TARGET_METRICS;
140
  currentRunList = runsSim.slice();
141
  updateDynamicPalette(); // Generate new colors based on run count
142
+ legendItems = currentRunList.map((name) => ({
143
+ name,
144
+ color: colorForRun(name),
145
+ }));
146
  updatePreparedData();
147
+ colorsByRun = Object.fromEntries(
148
+ currentRunList.map((name) => [name, colorForRun(name)]),
149
+ );
150
+
151
+ console.log(
152
+ `jitterData completed - generated ${wantRuns} runs with ${stepsCount} steps`,
153
+ ); // Debug log
154
  }
155
 
156
  // Public API: allow external theme switch
157
+ function setTheme(name) {
158
+ variant = name === "oblivion" ? "oblivion" : "classic";
159
  updateThemeClass();
160
+
161
  // Debug log for font application
162
+ if (typeof window !== "undefined") {
163
  console.log(`Theme switched to: ${variant}`);
164
  if (hostEl) {
165
  const computedStyle = getComputedStyle(hostEl);
 
185
 
186
  // Public API: generate massive test dataset
187
  function generateMassiveDataset(steps = null, runs = 3) {
188
+ console.log(
189
+ "🧪 Generating massive test dataset for sampling validation...",
190
+ );
191
+
192
  const result = generateMassiveTestDataset(steps, runs);
193
+
194
  // Update reactive data with massive dataset
195
  result.dataByMetric.forEach((v, k) => dataByMetric.set(k, v));
196
+ metricsToDraw = [
197
+ "epoch",
198
+ "train_accuracy",
199
+ "train_loss",
200
+ "val_accuracy",
201
+ "val_loss",
202
+ ];
203
  currentRunList = result.runNames.slice();
204
  updateDynamicPalette();
205
+ legendItems = currentRunList.map((name) => ({
206
+ name,
207
+ color: colorForRun(name),
208
+ }));
209
  updatePreparedData();
210
+ colorsByRun = Object.fromEntries(
211
+ currentRunList.map((name) => [name, colorForRun(name)]),
212
+ );
213
+
214
+ console.log(
215
+ `✅ Massive dataset loaded: ${result.stepCount} steps × ${result.runNames.length} runs`,
216
+ );
217
  console.log(`📊 Total data points: ${result.totalPoints.toLocaleString()}`);
218
  console.log(`🎯 Description: ${result.description}`);
219
+
220
  return result;
221
  }
222
 
223
  // Public API: add live data point for simulation
224
  function addLiveDataPoint(runName, dataPoint) {
225
  console.log(`Adding live data point for run "${runName}":`, dataPoint);
226
+
227
  // Add run to currentRunList if it doesn't exist
228
  if (!currentRunList.includes(runName)) {
229
  currentRunList = [...currentRunList, runName];
230
  updateDynamicPalette();
231
+ colorsByRun = Object.fromEntries(
232
+ currentRunList.map((name) => [name, colorForRun(name)]),
233
+ );
234
+ legendItems = currentRunList.map((name) => ({
235
+ name,
236
+ color: colorForRun(name),
237
+ }));
238
  }
239
+
240
  // Initialize data structures for the run if needed
241
+ const TARGET_METRICS = [
242
+ "epoch",
243
+ "train_accuracy",
244
+ "train_loss",
245
+ "val_accuracy",
246
+ "val_loss",
247
+ ];
248
+ TARGET_METRICS.forEach((metric) => {
249
  if (!dataByMetric.has(metric)) {
250
  dataByMetric.set(metric, {});
251
  }
 
254
  metricData[runName] = [];
255
  }
256
  });
257
+
258
  // Add the new data points to each metric
259
  const step = dataPoint.step;
260
+
261
  // Add epoch data
262
+ const epochData = dataByMetric.get("epoch");
263
  epochData[runName].push({ step, value: step });
264
+
265
  // Add accuracy data (train and val get the same value for simplicity)
266
  if (dataPoint.accuracy !== undefined) {
267
+ const trainAccData = dataByMetric.get("train_accuracy");
268
+ const valAccData = dataByMetric.get("val_accuracy");
269
+
270
  // Add some noise between train and val accuracy
271
  const trainAcc = dataPoint.accuracy;
272
+ const valAcc = Math.max(
273
+ 0,
274
+ Math.min(1, dataPoint.accuracy - 0.01 - Math.random() * 0.03),
275
+ );
276
+
277
  trainAccData[runName].push({ step, value: trainAcc });
278
  valAccData[runName].push({ step, value: valAcc });
279
  }
280
+
281
  // Add loss data (train and val get the same value for simplicity)
282
  if (dataPoint.loss !== undefined) {
283
+ const trainLossData = dataByMetric.get("train_loss");
284
+ const valLossData = dataByMetric.get("val_loss");
285
+
286
  // Add some noise between train and val loss
287
  const trainLoss = dataPoint.loss;
288
  const valLoss = dataPoint.loss + 0.05 + Math.random() * 0.1;
289
+
290
  trainLossData[runName].push({ step, value: trainLoss });
291
  valLossData[runName].push({ step, value: valLoss });
292
  }
293
+
294
  // Update all metrics to draw
295
  metricsToDraw = TARGET_METRICS;
296
+
297
  // Update prepared data with new values
298
  updatePreparedData();
299
+
300
+ console.log(
301
+ `Live data point added successfully. Total runs: ${currentRunList.length}`,
302
+ );
303
  }
304
 
305
  // Update prepared data with optional smoothing
306
  let preparedRawData = {}; // Store original data for background display
307
+
308
  function updatePreparedData() {
309
+ const TARGET_METRICS = [
310
+ "epoch",
311
+ "train_accuracy",
312
+ "train_loss",
313
+ "val_accuracy",
314
+ "val_loss",
315
+ ];
316
  let dataToUse = {};
317
  let rawDataToStore = {};
318
+
319
+ TARGET_METRICS.forEach((metric) => {
320
  const rawData = dataByMetric.get(metric);
321
  if (rawData) {
322
  // Store original data
323
  rawDataToStore[metric] = rawData;
324
+
325
  // Apply smoothing if enabled (except for epoch which should stay exact)
326
+ dataToUse[metric] =
327
+ smoothing && metric !== "epoch"
328
+ ? smoothMetricData(rawData, 5) // Window size of 5
329
+ : rawData;
330
  }
331
  });
332
+
333
  preparedData = dataToUse;
334
  preparedRawData = rawDataToStore;
335
  console.log(`Prepared data updated, smoothing: ${smoothing}`);
336
  }
337
 
338
+ function updateThemeClass() {
339
  if (!hostEl) return;
340
+ hostEl.classList.toggle("theme--classic", variant === "classic");
341
+ hostEl.classList.toggle("theme--oblivion", variant === "oblivion");
342
+ hostEl.setAttribute("data-variant", variant);
343
  }
344
 
345
  $: updateThemeClass();
346
 
 
347
  // Chart logic now handled by Cell.svelte
348
+
349
  // Fullscreen navigation state
350
  let currentFullscreenIndex = 0;
351
  let isModalOpen = false;
352
+
353
  function handleNavigate(newIndex) {
354
  currentFullscreenIndex = newIndex;
355
  }
356
+
357
  function openModal(index) {
358
  currentFullscreenIndex = index;
359
  isModalOpen = true;
360
  }
361
+
362
  function closeModal() {
363
  isModalOpen = false;
364
  }
365
+
366
  // Prepare all charts data for navigation
367
+ $: allChartsData = cellsDef.map((c) => ({
368
  metricKey: c.metric,
369
  titleText: c.title,
370
  metricData: (preparedData && preparedData[c.metric]) || {},
371
+ rawMetricData: (preparedRawData && preparedRawData[c.metric]) || {},
372
  }));
373
+
374
  // Color function for the modal
375
+ $: modalColorForRun = (name) => colorsByRun[name] || "#999";
376
 
377
  let cleanup = null;
378
  onMount(() => {
 
380
  hostEl.__setTheme = setTheme;
381
 
382
  // Jitter & Simulate functions
383
+ function rebuildLegend() {
384
  updateDynamicPalette(); // Update colors when adding new data
385
+ legendItems = currentRunList.map((name) => ({
386
+ name,
387
+ color: colorForRun(name),
388
+ }));
389
  }
390
+
391
+ function simulateData() {
392
  // Generate new random data with weighted probability for fewer runs
393
  // Higher probability for 2-3 runs, lower for 4-5-6 runs
394
  const rand = Math.random();
395
  let wantRuns;
396
+ if (rand < 0.4)
397
+ wantRuns = 2; // 40% chance
398
+ else if (rand < 0.7)
399
+ wantRuns = 3; // 30% chance
400
+ else if (rand < 0.85)
401
+ wantRuns = 4; // 15% chance
402
+ else if (rand < 0.95)
403
+ wantRuns = 5; // 10% chance
404
+ else wantRuns = 6; // 5% chance
405
  // Use realistic ML training step counts with cycling scenarios
406
  let stepsCount;
407
  if (cycleIdx === 0) {
408
+ stepsCount = Random.trainingStepsForScenario("prototyping");
409
  } else if (cycleIdx === 1) {
410
+ stepsCount = Random.trainingStepsForScenario("development");
411
  } else if (cycleIdx === 2) {
412
+ stepsCount = Random.trainingStepsForScenario("production");
413
  } else if (cycleIdx === 3) {
414
+ stepsCount = Random.trainingStepsForScenario("research");
415
  } else if (cycleIdx === 4) {
416
+ stepsCount = Random.trainingStepsForScenario("llm");
417
  } else if (cycleIdx === 5) {
418
+ stepsCount = Random.trainingStepsForScenario("massive");
419
  } else {
420
  stepsCount = Random.trainingSteps(); // Full range for variety
421
  }
422
  cycleIdx = (cycleIdx + 1) % 7; // Cycle through 7 scenarios now
423
+
424
  const runsSim = generateRunNames(wantRuns, stepsCount);
425
+ const steps = Array.from({ length: stepsCount }, (_, i) => i + 1);
426
  const nextByMetric = new Map();
427
+ const TARGET_METRICS = [
428
+ "epoch",
429
+ "train_accuracy",
430
+ "train_loss",
431
+ "val_accuracy",
432
+ "val_loss",
433
+ ];
434
+ const mList =
435
+ metricsToDraw && metricsToDraw.length ? metricsToDraw : TARGET_METRICS;
436
  mList.forEach((tgt) => {
437
  const map = {};
438
+ runsSim.forEach((r) => {
439
+ map[r] = [];
440
+ });
441
  nextByMetric.set(tgt, map);
442
  });
443
+ runsSim.forEach((run) => {
444
  const curves = genCurves(stepsCount);
445
+ steps.forEach((s, i) => {
446
+ if (mList.includes("epoch"))
447
+ nextByMetric.get("epoch")[run].push({ step: s, value: s });
448
+ if (mList.includes("train_accuracy"))
449
+ nextByMetric
450
+ .get("train_accuracy")
451
+ [run].push({ step: s, value: curves.accTrain[i] });
452
+ if (mList.includes("val_accuracy"))
453
+ nextByMetric
454
+ .get("val_accuracy")
455
+ [run].push({ step: s, value: curves.accVal[i] });
456
+ if (mList.includes("train_loss"))
457
+ nextByMetric
458
+ .get("train_loss")
459
+ [run].push({ step: s, value: curves.lossTrain[i] });
460
+ if (mList.includes("val_loss"))
461
+ nextByMetric
462
+ .get("val_loss")
463
+ [run].push({ step: s, value: curves.lossVal[i] });
464
  });
465
  });
466
+ nextByMetric.forEach((v, k) => dataByMetric.set(k, v));
467
  currentRunList = runsSim.slice();
468
  rebuildLegend();
469
  updatePreparedData();
470
  updateDynamicPalette(); // Update colors when rebuilding
471
+ colorsByRun = Object.fromEntries(
472
+ currentRunList.map((name) => [name, colorForRun(name)]),
473
+ );
474
  }
475
  // No need for event listeners anymore - we'll use reactive statement
476
 
 
478
  simulateData();
479
  // Svelte Cells will react to preparedData/colorsByRun updates
480
 
481
+ cleanup = () => {
482
  // No cleanup needed for reactive statements
483
  };
484
  });
485
 
486
+ onDestroy(() => {
487
+ if (cleanup) cleanup();
488
+ });
489
 
490
  // Expose instance for debugging and external theme control
491
  onMount(() => {
492
+ window.trackioInstance = {
493
+ jitterData,
494
+ addLiveDataPoint,
495
+ generateMassiveDataset,
496
+ };
497
  if (hostEl) {
498
+ hostEl.__trackioInstance = {
499
+ setTheme,
500
+ setLogScaleX,
501
+ setSmoothing,
502
+ jitterData,
503
+ addLiveDataPoint,
504
+ generateMassiveDataset,
505
+ };
506
  }
507
+
508
  // Initialize dynamic palette
509
  updateDynamicPalette();
510
+
511
  // Listen for palette updates from color-palettes.js
512
  const handlePaletteUpdate = () => {
513
  updateDynamicPalette();
514
  // Rebuild legend and colors if needed
515
  if (currentRunList.length > 0) {
516
+ legendItems = currentRunList.map((name) => ({
517
+ name,
518
+ color: colorForRun(name),
519
+ }));
520
+ colorsByRun = Object.fromEntries(
521
+ currentRunList.map((name) => [name, colorForRun(name)]),
522
+ );
523
  }
524
  };
525
+
526
+ document.addEventListener("palettes:updated", handlePaletteUpdate);
527
+
528
  // Cleanup listener on destroy
529
  return () => {
530
+ document.removeEventListener("palettes:updated", handlePaletteUpdate);
531
  };
532
  });
533
 
534
  // React to jitter trigger from store
535
  $: {
536
+ console.log(
537
+ "Reactive statement triggered, jitterTrigger value:",
538
+ $jitterTrigger,
539
+ );
540
  if ($jitterTrigger > 0) {
541
+ console.log(
542
+ "Jitter trigger activated:",
543
+ $jitterTrigger,
544
+ "calling jitterData()",
545
+ );
546
  jitterData();
547
  }
548
  }
549
 
550
  // Legend ghost helpers (hover effects)
551
+ function ghostRun(run) {
552
  try {
553
+ hostEl.classList.add("hovering");
554
+
555
  // Ghost the chart lines and points
556
+ hostEl.querySelectorAll(".cell").forEach((cell) => {
557
+ cell
558
+ .querySelectorAll("svg .lines path.run-line")
559
+ .forEach((p) =>
560
+ p.classList.toggle("ghost", p.getAttribute("data-run") !== run),
561
+ );
562
+ cell
563
+ .querySelectorAll("svg .lines path.raw-line")
564
+ .forEach((p) =>
565
+ p.classList.toggle("ghost", p.getAttribute("data-run") !== run),
566
+ );
567
+ cell
568
+ .querySelectorAll("svg .points circle.pt")
569
+ .forEach((c) =>
570
+ c.classList.toggle("ghost", c.getAttribute("data-run") !== run),
571
+ );
572
  });
573
+
574
  // Ghost the legend items
575
+ hostEl.querySelectorAll(".legend-bottom .item").forEach((item) => {
576
+ const itemRun = item.getAttribute("data-run");
577
+ item.classList.toggle("ghost", itemRun !== run);
578
  });
579
+ } catch (_) {}
580
  }
581
+ function clearGhost() {
582
  try {
583
+ hostEl.classList.remove("hovering");
584
+
585
  // Clear ghost from chart lines and points
586
+ hostEl.querySelectorAll(".cell").forEach((cell) => {
587
+ cell
588
+ .querySelectorAll("svg .lines path.run-line")
589
+ .forEach((p) => p.classList.remove("ghost"));
590
+ cell
591
+ .querySelectorAll("svg .lines path.raw-line")
592
+ .forEach((p) => p.classList.remove("ghost"));
593
+ cell
594
+ .querySelectorAll("svg .points circle.pt")
595
+ .forEach((c) => c.classList.remove("ghost"));
596
  });
597
+
598
  // Clear ghost from legend items
599
+ hostEl.querySelectorAll(".legend-bottom .item").forEach((item) => {
600
+ item.classList.remove("ghost");
601
  });
602
+ } catch (_) {}
603
  }
604
  </script>
605
 
606
  <div class="trackio theme--classic" bind:this={hostEl} data-variant={variant}>
607
  <div class="trackio__header">
608
+ <Legend
609
+ items={legendItems}
610
+ on:legend-hover={(e) => {
611
+ const run = e?.detail?.name;
612
+ if (!run) return;
613
+ ghostRun(run);
614
+ }}
615
+ on:legend-leave={() => {
616
+ clearGhost();
617
+ }}
618
+ />
619
  </div>
620
  <div class="trackio__grid" bind:this={gridEl}>
621
  {#each cellsDef as c, i}
622
+ <Cell
623
+ metricKey={c.metric}
624
+ titleText={c.title}
625
+ wide={c.wide}
626
+ {variant}
627
+ {normalizeLoss}
628
+ {logScaleX}
629
+ {smoothing}
630
+ metricData={(preparedData && preparedData[c.metric]) || {}}
631
+ rawMetricData={(preparedRawData && preparedRawData[c.metric]) || {}}
632
+ colorForRun={(name) => colorsByRun[name] || "#999"}
633
  {hostEl}
634
  currentIndex={i}
635
  onOpenModal={openModal}
 
638
  </div>
639
  <div class="trackio__footer">
640
  <small>
641
+ Built with <a
642
+ href="https://github.com/huggingface/trackio"
643
+ target="_blank"
644
+ rel="noopener noreferrer">TrackIO</a
645
+ >
646
  <span class="separator">•</span>
647
+ <a
648
+ href="https://huggingface.co/docs/hub/spaces-sdks-docker"
649
+ target="_blank"
650
+ rel="noopener noreferrer">Use via API</a
651
+ >
652
  </small>
653
  </div>
654
  </div>
 
656
  <!-- Centralized Fullscreen Modal -->
657
  <FullscreenModal
658
  visible={isModalOpen}
659
+ title={allChartsData[currentFullscreenIndex]?.titleText || ""}
660
  metricData={allChartsData[currentFullscreenIndex]?.metricData || {}}
661
  rawMetricData={allChartsData[currentFullscreenIndex]?.rawMetricData || {}}
662
  colorForRun={modalColorForRun}
 
664
  {logScaleX}
665
  {smoothing}
666
  {normalizeLoss}
667
+ metricKey={allChartsData[currentFullscreenIndex]?.metricKey || ""}
668
+ titleText={allChartsData[currentFullscreenIndex]?.titleText || ""}
669
  currentIndex={currentFullscreenIndex}
670
  totalCharts={cellsDef.length}
671
  onNavigate={handleNavigate}
 
678
  ========================= */
679
 
680
  /* Font imports for themes - ensure Roboto Mono is loaded for Oblivion theme */
681
+ @import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600;700&display=swap");
682
+
683
  /* Fallback font-face declaration */
684
  @font-face {
685
+ font-family: "Roboto Mono Fallback";
686
+ src: url("https://fonts.gstatic.com/s/robotomono/v23/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW4AJi8SJQt.woff2")
687
+ format("woff2");
688
  font-weight: 400;
689
  font-style: normal;
690
  font-display: swap;
 
695
  position: relative;
696
  --z-tooltip: 50;
697
  --z-overlay: 99999999;
698
+
699
  /* Typography */
700
+ --trackio-font-family: var(
701
+ --font-mono,
702
+ ui-monospace,
703
+ SFMono-Regular,
704
+ Menlo,
705
+ monospace
706
+ );
707
  --trackio-font-weight-normal: 400;
708
  --trackio-font-weight-medium: 600;
709
  --trackio-font-weight-bold: 700;
710
+
711
  /* Apply font-family to root element */
712
  font-family: var(--trackio-font-family);
713
+
714
  /* Base color system for Classic theme */
715
  --trackio-base: #323232;
716
  --trackio-primary: var(--trackio-base);
717
  --trackio-dim: color-mix(in srgb, var(--trackio-base) 28%, transparent);
718
  --trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
719
  --trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
720
+
721
  /* Chart rendering */
722
+ --trackio-chart-grid-type: "lines"; /* 'lines' | 'dots' */
723
  --trackio-chart-axis-stroke: var(--trackio-dim);
724
  --trackio-chart-axis-text: var(--trackio-text);
725
  --trackio-chart-grid-stroke: var(--trackio-subtle);
726
  --trackio-chart-grid-opacity: 1;
727
  }
728
+
729
  /* Dark mode overrides for Classic theme */
730
  :global([data-theme="dark"]) .trackio.theme--classic {
731
  --trackio-base: #ffffff;
 
733
  --trackio-dim: color-mix(in srgb, var(--trackio-base) 25%, transparent);
734
  --trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
735
  --trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
736
+
737
  /* Cell background for dark mode */
738
  --trackio-cell-background: rgba(255, 255, 255, 0.03);
739
  }
740
+
741
  .trackio.theme--classic {
742
  /* Cell styling */
743
  --trackio-cell-background: rgba(0, 0, 0, 0.02);
744
  --trackio-cell-border: var(--border-color, rgba(0, 0, 0, 0.1));
745
  --trackio-cell-corner-inset: 0px;
746
  --trackio-cell-gap: 12px;
747
+
748
  /* Typography */
749
  --trackio-text-primary: var(--text-color, rgba(0, 0, 0, 0.9));
750
  --trackio-text-secondary: var(--muted-color, rgba(0, 0, 0, 0.6));
751
+ --trackio-text-accent: var(--primary-color);
752
+
753
  /* Tooltip */
754
  --trackio-tooltip-background: var(--surface-bg, white);
755
  --trackio-tooltip-border: var(--border-color, rgba(0, 0, 0, 0.1));
756
  --trackio-tooltip-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
757
+
758
  /* Legend */
759
  --trackio-legend-text: var(--text-color, rgba(0, 0, 0, 0.9));
760
  --trackio-legend-swatch-border: var(--border-color, rgba(0, 0, 0, 0.1));
 
763
  /* Dark mode adjustments */
764
  :global([data-theme="dark"]) .trackio {
765
  --trackio-chart-axis-stroke: rgba(255, 255, 255, 0.18);
766
+ --trackio-chart-axis-text: rgba(255, 255, 255, 0.6);
767
  --trackio-chart-grid-stroke: rgba(255, 255, 255, 0.08);
768
  }
769
 
770
  /* =========================
771
  THEME: CLASSIC (Default)
772
  ========================= */
773
+
774
  .trackio.theme--classic {
775
  /* Keep default values - no overrides needed */
776
  }
 
778
  /* =========================
779
  THEME: OBLIVION
780
  ========================= */
781
+
782
  .trackio.theme--oblivion {
783
  /* Core oblivion color system - Light mode: darker colors for visibility */
784
  --trackio-oblivion-base: #2a2a2a;
785
  --trackio-oblivion-primary: var(--trackio-oblivion-base);
786
+ --trackio-oblivion-dim: color-mix(
787
+ in srgb,
788
+ var(--trackio-oblivion-base) 30%,
789
+ transparent
790
+ );
791
+ --trackio-oblivion-subtle: color-mix(
792
+ in srgb,
793
+ var(--trackio-oblivion-base) 8%,
794
+ transparent
795
+ );
796
+ --trackio-oblivion-ghost: color-mix(
797
+ in srgb,
798
+ var(--trackio-oblivion-base) 4%,
799
+ transparent
800
+ );
801
+
802
  /* Chart rendering overrides */
803
+ --trackio-chart-grid-type: "dots";
804
  --trackio-chart-axis-stroke: var(--trackio-oblivion-dim);
805
  --trackio-chart-axis-text: var(--trackio-oblivion-primary);
806
  --trackio-chart-grid-stroke: var(--trackio-oblivion-dim);
807
  --trackio-chart-grid-opacity: 0.6;
808
  }
809
+
810
  /* Dark mode overrides for Oblivion theme */
811
  :global([data-theme="dark"]) .trackio.theme--oblivion {
812
  --trackio-oblivion-base: #ffffff;
813
  --trackio-oblivion-primary: var(--trackio-oblivion-base);
814
+ --trackio-oblivion-dim: color-mix(
815
+ in srgb,
816
+ var(--trackio-oblivion-base) 25%,
817
+ transparent
818
+ );
819
+ --trackio-oblivion-subtle: color-mix(
820
+ in srgb,
821
+ var(--trackio-oblivion-base) 8%,
822
+ transparent
823
+ );
824
+ --trackio-oblivion-ghost: color-mix(
825
+ in srgb,
826
+ var(--trackio-oblivion-base) 4%,
827
+ transparent
828
+ );
829
  }
830
+
831
  .trackio.theme--oblivion {
832
  /* Cell styling overrides */
833
  --trackio-cell-background: var(--trackio-oblivion-subtle);
834
  --trackio-cell-border: var(--trackio-oblivion-dim);
835
  --trackio-cell-corner-inset: 6px;
836
  --trackio-cell-gap: 0px;
837
+
838
  /* HUD-specific variables */
839
  --trackio-oblivion-hud-gap: 10px;
840
  --trackio-oblivion-hud-corner-size: 8px;
841
+ --trackio-oblivion-hud-bg-gradient: radial-gradient(
842
+ 1200px 200px at 20% -10%,
843
+ var(--trackio-oblivion-ghost),
844
+ transparent 80%
845
+ ),
846
+ radial-gradient(
847
+ 900px 200px at 80% 110%,
848
+ var(--trackio-oblivion-ghost),
849
+ transparent 80%
850
+ );
851
+
852
  /* Typography overrides */
853
  --trackio-text-primary: var(--trackio-oblivion-primary);
854
  --trackio-text-secondary: var(--trackio-oblivion-dim);
855
  --trackio-text-accent: var(--trackio-oblivion-primary);
856
+
857
  /* Tooltip overrides */
858
  --trackio-tooltip-background: var(--trackio-oblivion-subtle);
859
  --trackio-tooltip-border: var(--trackio-oblivion-dim);
860
+ --trackio-tooltip-shadow: 0 8px 32px
861
+ color-mix(in srgb, var(--trackio-oblivion-base) 8%, transparent),
862
  0 2px 8px color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent);
863
+
864
  /* Legend overrides */
865
  --trackio-legend-text: var(--trackio-oblivion-primary);
866
  --trackio-legend-swatch-border: var(--trackio-oblivion-dim);
867
+
868
  /* Font styling overrides */
869
+ --trackio-font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
870
+ SFMono-Regular, Menlo, monospace;
871
  font-family: var(--trackio-font-family) !important;
872
  color: var(--trackio-text-primary);
873
  }
 
875
  /* Force Roboto Mono application in Oblivion theme */
876
  .trackio.theme--oblivion,
877
  .trackio.theme--oblivion * {
878
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
879
+ SFMono-Regular, Menlo, monospace !important;
880
  }
881
+
882
  /* Specific overrides for different elements in Oblivion */
883
  .trackio.theme--oblivion .cell-title,
884
  .trackio.theme--oblivion .legend-bottom,
885
  .trackio.theme--oblivion .legend-title,
886
  .trackio.theme--oblivion .item {
887
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
888
+ SFMono-Regular, Menlo, monospace !important;
889
  }
890
 
891
  /* Dark mode adjustments for Oblivion */
892
  :global([data-theme="dark"]) .trackio.theme--oblivion {
893
  --trackio-oblivion-base: #ffffff;
894
+ --trackio-oblivion-hud-bg-gradient: radial-gradient(
895
+ 1400px 260px at 20% -10%,
896
+ color-mix(in srgb, var(--trackio-oblivion-base) 6.5%, transparent),
897
+ transparent 80%
898
+ ),
899
+ radial-gradient(
900
+ 1100px 240px at 80% 110%,
901
+ color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent),
902
+ transparent 80%
903
+ ),
904
+ linear-gradient(
905
+ 180deg,
906
+ color-mix(in srgb, var(--trackio-oblivion-base) 3.5%, transparent),
907
+ transparent 45%
908
+ );
909
+
910
+ --trackio-tooltip-shadow: 0 8px 32px
911
+ color-mix(in srgb, var(--trackio-oblivion-base) 5%, transparent),
912
  0 2px 8px color-mix(in srgb, black 10%, transparent);
913
+
914
  background: #0f1115;
915
  }
916
 
 
923
  grid-template-columns: repeat(2, minmax(0, 1fr));
924
  gap: var(--trackio-cell-gap);
925
  }
926
+
927
  @media (max-width: 980px) {
928
+ .trackio__grid {
929
+ grid-template-columns: 1fr;
930
+ }
931
  }
932
 
933
  .trackio__header {
 
945
  .trackio .axes line {
946
  stroke: var(--trackio-chart-axis-stroke);
947
  }
948
+
949
  .trackio .axes text {
950
  fill: var(--trackio-chart-axis-text);
951
  font-family: var(--trackio-font-family);
952
  }
953
+
954
  /* Force font-family for SVG text in Oblivion */
955
  .trackio.theme--oblivion .axes text {
956
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
957
+ SFMono-Regular, Menlo, monospace !important;
958
  }
959
+
960
  .trackio .grid line {
961
  stroke: var(--trackio-chart-grid-stroke);
962
  opacity: var(--trackio-chart-grid-opacity);
963
  }
964
 
965
  /* Grid type switching */
966
+ .trackio .grid-dots {
967
+ display: none;
968
+ }
969
+ .trackio.theme--oblivion .grid {
970
+ display: none;
971
+ }
972
+ .trackio.theme--oblivion .grid-dots {
973
+ display: block;
974
+ }
975
  .trackio.theme--oblivion .cell-bg,
976
+ .trackio.theme--oblivion .cell-corners {
977
+ display: block;
978
+ }
979
 
980
  /* =========================
981
  FOOTER
 
1019
  }
1020
 
1021
  .trackio.theme--oblivion .trackio__footer small {
1022
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
1023
+ SFMono-Regular, Menlo, monospace !important;
1024
  }
1025
  </style>
 
 
app/src/components/trackio/TrackioWrapper.astro ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ // TrackioWrapper.astro
3
+ import Trackio from "./Trackio.svelte";
4
+ ---
5
+
6
+ <!-- Ensure Roboto Mono is loaded for Oblivion theme -->
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+
14
+ <div class="trackio-wrapper">
15
+ <div class="trackio-controls">
16
+ <div class="controls-left">
17
+ <div class="theme-selector">
18
+ <label for="theme-select">Theme</label>
19
+ <select id="theme-select" class="theme-select">
20
+ <option value="classic">Classic</option>
21
+ <option value="oblivion">Oblivion</option>
22
+ </select>
23
+ </div>
24
+ <div class="scale-controls">
25
+ <label>
26
+ <input type="checkbox" id="log-scale-x" checked />
27
+ Log Scale X
28
+ </label>
29
+ <label>
30
+ <input type="checkbox" id="smooth-data" checked />
31
+ Smooth
32
+ </label>
33
+ </div>
34
+ </div>
35
+ <div class="controls-right">
36
+ <button class="button button--ghost" type="button" id="randomize-btn">
37
+ Randomize Data
38
+ </button>
39
+ <button
40
+ class="button button--primary"
41
+ type="button"
42
+ id="start-simulation-btn"
43
+ >
44
+ Live Run
45
+ </button>
46
+ <button
47
+ class="button button--danger"
48
+ type="button"
49
+ id="stop-simulation-btn"
50
+ style="display: none;"
51
+ >
52
+ Stop
53
+ </button>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="trackio-container">
58
+ <Trackio client:load variant="classic" logScaleX={true} smoothing={true} />
59
+ </div>
60
+ </div>
61
+
62
+ <script>
63
+ // @ts-nocheck
64
+ document.addEventListener("DOMContentLoaded", async () => {
65
+ const themeSelect = document.getElementById("theme-select");
66
+ const randomizeBtn = document.getElementById("randomize-btn");
67
+ const startSimulationBtn = document.getElementById("start-simulation-btn");
68
+ const stopSimulationBtn = document.getElementById("stop-simulation-btn");
69
+ const logScaleXCheckbox = document.getElementById("log-scale-x");
70
+ const smoothDataCheckbox = document.getElementById("smooth-data");
71
+ const trackioContainer = document.querySelector(".trackio-container");
72
+
73
+ if (
74
+ !themeSelect ||
75
+ !randomizeBtn ||
76
+ !startSimulationBtn ||
77
+ !stopSimulationBtn ||
78
+ !logScaleXCheckbox ||
79
+ !smoothDataCheckbox ||
80
+ !trackioContainer
81
+ )
82
+ return;
83
+
84
+ // Variables for simulation
85
+ let simulationInterval = null;
86
+ let currentSimulationRun = null;
87
+ let currentStep = 0;
88
+
89
+ // Import the store function
90
+ const { triggerJitter } = await import("./core/store.js");
91
+
92
+ // Theme change handler
93
+ themeSelect.addEventListener("change", (e) => {
94
+ const target = e.target;
95
+ if (!target || !("value" in target)) return;
96
+
97
+ const newVariant = target.value;
98
+ console.log(`Theme changed to: ${newVariant}`); // Debug log
99
+
100
+ // Find the trackio element and call setTheme on the Svelte instance
101
+ const trackioEl = debugTrackioState();
102
+ if (trackioEl && trackioEl.__trackioInstance) {
103
+ console.log("✅ Calling setTheme on Trackio instance");
104
+ trackioEl.__trackioInstance.setTheme(newVariant);
105
+ } else {
106
+ console.warn("❌ No Trackio instance found for theme change");
107
+ }
108
+ });
109
+
110
+ // Log scale X change handler
111
+ logScaleXCheckbox.addEventListener("change", (e) => {
112
+ const target = e.target;
113
+ if (!target || !("checked" in target)) return;
114
+
115
+ const isLogScale = target.checked;
116
+ console.log(`Log scale X changed to: ${isLogScale}`); // Debug log
117
+
118
+ // Find the trackio element and call setLogScaleX on the Svelte instance
119
+ const trackioEl = debugTrackioState();
120
+ if (trackioEl && trackioEl.__trackioInstance) {
121
+ console.log("✅ Calling setLogScaleX on Trackio instance");
122
+ trackioEl.__trackioInstance.setLogScaleX(isLogScale);
123
+ } else {
124
+ console.warn("❌ Trackio instance not found for log scale change");
125
+ }
126
+ });
127
+
128
+ // Smooth data change handler
129
+ smoothDataCheckbox.addEventListener("change", (e) => {
130
+ const target = e.target;
131
+ if (!target || !("checked" in target)) return;
132
+
133
+ const isSmooth = target.checked;
134
+ console.log(`Smooth data changed to: ${isSmooth}`); // Debug log
135
+
136
+ // Find the trackio element and call setSmoothing on the Svelte instance
137
+ const trackioEl = debugTrackioState();
138
+ if (trackioEl && trackioEl.__trackioInstance) {
139
+ console.log("✅ Calling setSmoothing on Trackio instance");
140
+ trackioEl.__trackioInstance.setSmoothing(isSmooth);
141
+ } else {
142
+ console.warn("❌ Trackio instance not found for smooth change");
143
+ }
144
+ });
145
+
146
+ // Debug function to check trackio state
147
+ function debugTrackioState() {
148
+ const trackioEl = trackioContainer.querySelector(".trackio");
149
+ console.log("�� Debug Trackio state:", {
150
+ container: !!trackioContainer,
151
+ trackioEl: !!trackioEl,
152
+ hasInstance: !!(trackioEl && trackioEl.__trackioInstance),
153
+ availableMethods:
154
+ trackioEl && trackioEl.__trackioInstance
155
+ ? Object.keys(trackioEl.__trackioInstance)
156
+ : "none",
157
+ windowInstance: !!window.trackioInstance,
158
+ });
159
+ return trackioEl;
160
+ }
161
+
162
+ // Initialize with default checked states - increased delay and retry logic
163
+ function initializeTrackio(attempt = 1) {
164
+ console.log(`🚀 Initializing Trackio (attempt ${attempt})`);
165
+
166
+ const trackioEl = debugTrackioState();
167
+
168
+ if (trackioEl && trackioEl.__trackioInstance) {
169
+ console.log("✅ Trackio instance found, applying initial settings");
170
+
171
+ if (logScaleXCheckbox.checked) {
172
+ console.log("Initializing with log scale X enabled");
173
+ trackioEl.__trackioInstance.setLogScaleX(true);
174
+ }
175
+
176
+ if (smoothDataCheckbox.checked) {
177
+ console.log("Initializing with smoothing enabled");
178
+ trackioEl.__trackioInstance.setSmoothing(true);
179
+ }
180
+ } else {
181
+ console.log("❌ Trackio instance not ready yet");
182
+ if (attempt < 10) {
183
+ setTimeout(() => initializeTrackio(attempt + 1), 200 * attempt);
184
+ } else {
185
+ console.error("Failed to initialize Trackio after 10 attempts");
186
+ }
187
+ }
188
+ }
189
+
190
+ // Start initialization
191
+ setTimeout(() => initializeTrackio(), 100);
192
+
193
+ // Function to generate a new simulated metric value
194
+ function generateSimulatedValue(step, metric) {
195
+ const baseProgress = Math.min(1, step / 100); // Normalise sur 100 steps
196
+
197
+ if (metric === "loss") {
198
+ // Loss that decreases with noise
199
+ const baseLoss = 2.0 * Math.exp(-0.05 * step);
200
+ const noise = (Math.random() - 0.5) * 0.2;
201
+ return Math.max(0.01, baseLoss + noise);
202
+ } else if (metric === "accuracy") {
203
+ // Accuracy that increases with noise
204
+ const baseAcc = 0.1 + 0.8 * (1 - Math.exp(-0.04 * step));
205
+ const noise = (Math.random() - 0.5) * 0.05;
206
+ return Math.max(0, Math.min(1, baseAcc + noise));
207
+ }
208
+ return Math.random();
209
+ }
210
+
211
+ // Handler to start simulation
212
+ function startSimulation() {
213
+ if (simulationInterval) {
214
+ clearInterval(simulationInterval);
215
+ }
216
+
217
+ // Générer un nouveau nom de run
218
+ const adjectives = [
219
+ "live",
220
+ "real-time",
221
+ "streaming",
222
+ "dynamic",
223
+ "active",
224
+ "running",
225
+ ];
226
+ const nouns = [
227
+ "experiment",
228
+ "trial",
229
+ "session",
230
+ "training",
231
+ "run",
232
+ "test",
233
+ ];
234
+ const randomAdj =
235
+ adjectives[Math.floor(Math.random() * adjectives.length)];
236
+ const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
237
+ currentSimulationRun = `${randomAdj}-${randomNoun}-${Date.now().toString().slice(-4)}`;
238
+ currentStep = 1; // Commencer à step 1
239
+
240
+ console.log(`Starting simulation for run: ${currentSimulationRun}`);
241
+
242
+ // Interface UI
243
+ startSimulationBtn.style.display = "none";
244
+ stopSimulationBtn.style.display = "inline-flex";
245
+ startSimulationBtn.disabled = true;
246
+
247
+ // Ajouter le premier point
248
+ addSimulationStep();
249
+
250
+ // Continuer chaque seconde
251
+ simulationInterval = setInterval(() => {
252
+ currentStep++;
253
+ addSimulationStep();
254
+
255
+ // Stop after 200 steps to avoid infinity
256
+ if (currentStep > 200) {
257
+ stopSimulation();
258
+ }
259
+ }, 1000); // Chaque seconde
260
+ }
261
+
262
+ // Function to add a new data point
263
+ function addSimulationStep() {
264
+ const trackioEl = trackioContainer.querySelector(".trackio");
265
+ if (trackioEl && trackioEl.__trackioInstance) {
266
+ const newDataPoint = {
267
+ step: currentStep,
268
+ loss: generateSimulatedValue(currentStep, "loss"),
269
+ accuracy: generateSimulatedValue(currentStep, "accuracy"),
270
+ };
271
+
272
+ console.log(
273
+ `Adding simulation step ${currentStep} for run ${currentSimulationRun}:`,
274
+ newDataPoint,
275
+ );
276
+
277
+ // Ajouter le point via l'instance Trackio
278
+ if (
279
+ typeof trackioEl.__trackioInstance.addLiveDataPoint === "function"
280
+ ) {
281
+ trackioEl.__trackioInstance.addLiveDataPoint(
282
+ currentSimulationRun,
283
+ newDataPoint,
284
+ );
285
+ } else {
286
+ console.warn("addLiveDataPoint method not found on Trackio instance");
287
+ }
288
+ }
289
+ }
290
+
291
+ // Handler to stop simulation
292
+ function stopSimulation() {
293
+ if (simulationInterval) {
294
+ clearInterval(simulationInterval);
295
+ simulationInterval = null;
296
+ }
297
+
298
+ console.log(`Stopping simulation for run: ${currentSimulationRun}`);
299
+
300
+ // Interface UI
301
+ startSimulationBtn.style.display = "inline-flex";
302
+ stopSimulationBtn.style.display = "none";
303
+ startSimulationBtn.disabled = false;
304
+
305
+ currentSimulationRun = null;
306
+ currentStep = 0;
307
+ }
308
+
309
+ // Event listeners for simulation buttons
310
+ startSimulationBtn.addEventListener("click", startSimulation);
311
+ stopSimulationBtn.addEventListener("click", stopSimulation);
312
+
313
+ // Arrêter la simulation si l'utilisateur quitte la page
314
+ window.addEventListener("beforeunload", stopSimulation);
315
+
316
+ // Randomize data handler - now uses the store
317
+ randomizeBtn.addEventListener("click", () => {
318
+ console.log("Randomize button clicked - triggering jitter via store"); // Debug log
319
+
320
+ // Arrêter la simulation en cours si elle tourne
321
+ if (simulationInterval) {
322
+ stopSimulation();
323
+ }
324
+
325
+ // Add vibration animation
326
+ randomizeBtn.classList.add("vibrating");
327
+ setTimeout(() => {
328
+ randomizeBtn.classList.remove("vibrating");
329
+ }, 600);
330
+
331
+ // Test direct window approach as well
332
+ if (
333
+ window.trackioInstance &&
334
+ typeof window.trackioInstance.jitterData === "function"
335
+ ) {
336
+ console.log(
337
+ "Found window.trackioInstance, calling jitterData directly",
338
+ ); // Debug log
339
+ window.trackioInstance.jitterData();
340
+ } else {
341
+ console.log("No window.trackioInstance found, using store trigger"); // Debug log
342
+ triggerJitter();
343
+ }
344
+ });
345
+ });
346
+ </script>
347
+
348
+ <style>
349
+ .trackio-wrapper {
350
+ width: 100%;
351
+ margin: 0px 0 20px 0;
352
+ }
353
+
354
+ .trackio-controls {
355
+ display: flex;
356
+ justify-content: space-between;
357
+ align-items: center;
358
+ margin-bottom: 16px;
359
+ padding: 12px 0px;
360
+ /* border-bottom: 1px solid var(--border-color); */
361
+ gap: 16px;
362
+ flex-wrap: nowrap;
363
+ }
364
+
365
+ .controls-left {
366
+ display: flex;
367
+ align-items: center;
368
+ gap: 24px;
369
+ flex-wrap: wrap;
370
+ }
371
+
372
+ .controls-right {
373
+ display: flex;
374
+ align-items: center;
375
+ gap: 12px;
376
+ flex-wrap: wrap;
377
+ }
378
+
379
+ .btn-randomize {
380
+ display: inline-flex;
381
+ align-items: center;
382
+ gap: 6px;
383
+ padding: 8px 16px;
384
+ background: var(--accent-color, #007acc);
385
+ color: white;
386
+ border: none;
387
+ border-radius: 6px;
388
+ font-size: 14px;
389
+ font-weight: 500;
390
+ cursor: pointer;
391
+ transition: all 0.15s ease;
392
+ }
393
+
394
+ .btn-randomize:hover {
395
+ background: var(--accent-hover, #005a9e);
396
+ transform: translateY(-1px);
397
+ }
398
+
399
+ .btn-randomize:active {
400
+ transform: translateY(0);
401
+ }
402
+
403
+ .theme-selector {
404
+ display: flex;
405
+ align-items: center;
406
+ gap: 8px;
407
+ font-size: 14px;
408
+ flex-shrink: 0;
409
+ white-space: nowrap;
410
+ }
411
+
412
+ .theme-selector label {
413
+ font-weight: 500;
414
+ color: var(--text-color);
415
+ }
416
+
417
+ .theme-select {
418
+ padding: 6px 12px;
419
+ border: 1px solid var(--border-color);
420
+ border-radius: 4px;
421
+ background: var(--input-bg, var(--surface-bg));
422
+ color: var(--text-color);
423
+ font-size: 14px;
424
+ cursor: pointer;
425
+ transition: border-color 0.15s ease;
426
+ }
427
+
428
+ .theme-select:focus {
429
+ outline: none;
430
+ border-color: var(--accent-color, #007acc);
431
+ }
432
+
433
+ .scale-controls {
434
+ display: flex;
435
+ align-items: center;
436
+ gap: 16px;
437
+ flex-shrink: 0;
438
+ white-space: nowrap;
439
+ }
440
+
441
+ /* Vibration animation for button */
442
+ @keyframes vibrate {
443
+ 0% {
444
+ transform: translateX(0);
445
+ }
446
+ 10% {
447
+ transform: translateX(-2px) rotate(-1deg);
448
+ }
449
+ 20% {
450
+ transform: translateX(2px) rotate(1deg);
451
+ }
452
+ 30% {
453
+ transform: translateX(-2px) rotate(-1deg);
454
+ }
455
+ 40% {
456
+ transform: translateX(2px) rotate(1deg);
457
+ }
458
+ 50% {
459
+ transform: translateX(-1px) rotate(-0.5deg);
460
+ }
461
+ 60% {
462
+ transform: translateX(1px) rotate(0.5deg);
463
+ }
464
+ 70% {
465
+ transform: translateX(-1px) rotate(-0.5deg);
466
+ }
467
+ 80% {
468
+ transform: translateX(1px) rotate(0.5deg);
469
+ }
470
+ 90% {
471
+ transform: translateX(-0.5px) rotate(-0.25deg);
472
+ }
473
+ 100% {
474
+ transform: translateX(0) rotate(0);
475
+ }
476
+ }
477
+
478
+ .button.vibrating {
479
+ animation: vibrate 0.6s ease-in-out;
480
+ }
481
+
482
+ .trackio-container {
483
+ width: 100%;
484
+ margin-top: 10px;
485
+ border: 1px solid var(--border-color);
486
+ padding: 24px 12px;
487
+ }
488
+
489
+ @media (max-width: 768px) {
490
+ .trackio-controls {
491
+ flex-direction: column;
492
+ align-items: stretch;
493
+ gap: 12px;
494
+ }
495
+
496
+ .controls-left {
497
+ flex-direction: column;
498
+ align-items: stretch;
499
+ gap: 12px;
500
+ }
501
+
502
+ .theme-selector {
503
+ justify-content: space-between;
504
+ }
505
+
506
+ .scale-controls {
507
+ justify-content: space-between;
508
+ }
509
+ }
510
+ </style>
app/src/components/trackio/core/adaptive-sampler.js CHANGED
@@ -7,24 +7,24 @@
7
  export class AdaptiveSampler {
8
  constructor(options = {}) {
9
  this.options = {
10
- maxPoints: 400, // Seuil pour déclencher le sampling
11
- targetPoints: 200, // Nombre cible de points après sampling
12
- preserveFeatures: true, // Préserver les pics/vallées importantes
13
  adaptiveStrategy: 'smart', // 'uniform', 'smart', 'lod'
14
- smoothingWindow: 3, // Fenêtre pour détection des features
15
  ...options
16
  };
17
  }
18
 
19
  /**
20
- * Détermine si le sampling est nécessaire
21
  */
22
  needsSampling(dataLength) {
23
  return dataLength > this.options.maxPoints;
24
  }
25
 
26
  /**
27
- * Point d'entrée principal pour le sampling
28
  */
29
  sampleSeries(data, strategy = null) {
30
  if (!Array.isArray(data) || data.length === 0) {
@@ -234,11 +234,11 @@ export class AdaptiveSampler {
234
  const index = Math.floor(logProgress * (totalLength - 1));
235
  indices.push(Math.max(1, Math.min(totalLength - 2, index)));
236
  }
237
- return [...new Set(indices)]; // Supprimer les doublons
238
  }
239
 
240
  /**
241
- * Échantillonnage basé sur la variation locale
242
  */
243
  sampleByVariation(data, targetPoints) {
244
  const variations = [];
@@ -290,7 +290,7 @@ export class AdaptiveSampler {
290
  * Reconstruit les données complètes pour une zone spécifique (pour le zoom)
291
  */
292
  getFullDataForRange(originalData, samplingInfo, startStep, endStep) {
293
- // Cette méthode permettrait de récupérer plus de détails
294
  // quand l'utilisateur zoom sur une zone spécifique
295
  const startIdx = originalData.findIndex(d => d.step >= startStep);
296
  const endIdx = originalData.findIndex(d => d.step > endStep);
 
7
  export class AdaptiveSampler {
8
  constructor(options = {}) {
9
  this.options = {
10
+ maxPoints: 400, // Threshold to trigger sampling
11
+ targetPoints: 200, // Target number of points after sampling
12
+ preserveFeatures: true, // Preserve important peaks/valleys
13
  adaptiveStrategy: 'smart', // 'uniform', 'smart', 'lod'
14
+ smoothingWindow: 3, // Window for feature detection
15
  ...options
16
  };
17
  }
18
 
19
  /**
20
+ * Determine if sampling is necessary
21
  */
22
  needsSampling(dataLength) {
23
  return dataLength > this.options.maxPoints;
24
  }
25
 
26
  /**
27
+ * Main entry point for sampling
28
  */
29
  sampleSeries(data, strategy = null) {
30
  if (!Array.isArray(data) || data.length === 0) {
 
234
  const index = Math.floor(logProgress * (totalLength - 1));
235
  indices.push(Math.max(1, Math.min(totalLength - 2, index)));
236
  }
237
+ return [...new Set(indices)]; // Remove duplicates
238
  }
239
 
240
  /**
241
+ * Sampling based on local variation
242
  */
243
  sampleByVariation(data, targetPoints) {
244
  const variations = [];
 
290
  * Reconstruit les données complètes pour une zone spécifique (pour le zoom)
291
  */
292
  getFullDataForRange(originalData, samplingInfo, startStep, endStep) {
293
+ // This method would allow recovering more details
294
  // quand l'utilisateur zoom sur une zone spécifique
295
  const startIdx = originalData.findIndex(d => d.step >= startStep);
296
  const endIdx = originalData.findIndex(d => d.step > endStep);
app/src/content/embeds/banner.html ADDED
@@ -0,0 +1,633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-robot-arm" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></div>
2
+ <script>
3
+ (() => {
4
+ const ensureD3 = (cb) => {
5
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
6
+ let s = document.getElementById('d3-cdn-script');
7
+ if (!s) {
8
+ s = document.createElement('script');
9
+ s.id = 'd3-cdn-script';
10
+ s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
11
+ document.head.appendChild(s);
12
+ }
13
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
14
+ s.addEventListener('load', onReady, { once: true });
15
+ if (window.d3) onReady();
16
+ };
17
+
18
+ const bootstrap = () => {
19
+ const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
20
+ const container = (mount && mount.querySelector && mount.querySelector('.d3-robot-arm')) || document.querySelector('.d3-robot-arm');
21
+ if (!container) return;
22
+ if (container.dataset) {
23
+ if (container.dataset.mounted === 'true') return;
24
+ container.dataset.mounted = 'true';
25
+ }
26
+
27
+ // Robot arm parameters
28
+ const armLengths = [120, 100, 80]; // 3 segments
29
+ const numSegments = armLengths.length;
30
+
31
+ // Trail for end effector
32
+ const trailLength = 80;
33
+ const trail = [];
34
+
35
+ // Animation state
36
+ let targetX = 0, targetY = 0;
37
+ let currentX = 0, currentY = 0; // Smooth interpolated position
38
+ let time = 0;
39
+ let prevPositions = null;
40
+
41
+ // Task system
42
+ let currentTask = 0;
43
+ let taskProgress = 0;
44
+ let gripperOpen = true;
45
+ let gripperOpenness = 1.0; // 0 = closed, 1 = open (for smooth animation)
46
+ let heldObject = null;
47
+
48
+ // Objects to manipulate with initial and target positions
49
+ const objects = [
50
+ { id: 1, x: 0, y: 0, targetX: 0, targetY: 0, size: 25, color: 0.2, label: 'Cube A', shape: 'square', placed: false },
51
+ { id: 2, x: 0, y: 0, targetX: 0, targetY: 0, size: 20, color: 0.5, label: 'Ball B', shape: 'circle', placed: false },
52
+ { id: 3, x: 0, y: 0, targetX: 0, targetY: 0, size: 22, color: 0.8, label: 'Cube C', shape: 'square', placed: false }
53
+ ];
54
+
55
+ // Task sequences optimized for smooth reach
56
+ const tasks = [
57
+ { type: 'pick', objectId: 1, duration: 100 },
58
+ { type: 'move', x: 0.3, y: 0.5, duration: 90 },
59
+ { type: 'place', duration: 60 },
60
+ { type: 'return', duration: 80 },
61
+ { type: 'pick', objectId: 2, duration: 100 },
62
+ { type: 'move', x: 0.5, y: 0.6, duration: 90 },
63
+ { type: 'place', duration: 60 },
64
+ { type: 'return', duration: 80 },
65
+ { type: 'pick', objectId: 3, duration: 100 },
66
+ { type: 'move', x: 0.7, y: 0.5, duration: 90 },
67
+ { type: 'place', duration: 60 },
68
+ { type: 'idle', duration: 100 }
69
+ ];
70
+
71
+ // FABRIK Inverse Kinematics solver (Forward And Backward Reaching)
72
+ const solveIK = (targetX, targetY, baseX, baseY) => {
73
+ // Initialize joint positions (use previous if available, otherwise extend horizontally)
74
+ let positions;
75
+
76
+ if (prevPositions && prevPositions.length === numSegments + 1) {
77
+ // Use previous positions as starting point for smooth animation
78
+ positions = prevPositions.map(p => ({ x: p.x, y: p.y }));
79
+ positions[0] = { x: baseX, y: baseY }; // Always fix base
80
+ } else {
81
+ // Initial position: extend arm horizontally to the right
82
+ positions = [{ x: baseX, y: baseY }];
83
+ let x = baseX, y = baseY;
84
+ for (let i = 0; i < numSegments; i++) {
85
+ x += armLengths[i];
86
+ positions.push({ x, y });
87
+ }
88
+ }
89
+
90
+ // FABRIK iterations
91
+ const maxIterations = 10;
92
+ const tolerance = 0.1;
93
+
94
+ for (let iter = 0; iter < maxIterations; iter++) {
95
+ // Forward reaching: start from end effector
96
+ positions[numSegments].x = targetX;
97
+ positions[numSegments].y = targetY;
98
+
99
+ for (let i = numSegments - 1; i >= 0; i--) {
100
+ const dx = positions[i].x - positions[i + 1].x;
101
+ const dy = positions[i].y - positions[i + 1].y;
102
+ const dist = Math.sqrt(dx * dx + dy * dy);
103
+
104
+ if (dist > 0) {
105
+ const lambda = armLengths[i] / dist;
106
+ positions[i].x = positions[i + 1].x + dx * lambda;
107
+ positions[i].y = positions[i + 1].y + dy * lambda;
108
+ }
109
+ }
110
+
111
+ // Backward reaching: start from base
112
+ positions[0].x = baseX;
113
+ positions[0].y = baseY;
114
+
115
+ for (let i = 0; i < numSegments; i++) {
116
+ const dx = positions[i + 1].x - positions[i].x;
117
+ const dy = positions[i + 1].y - positions[i].y;
118
+ const dist = Math.sqrt(dx * dx + dy * dy);
119
+
120
+ if (dist > 0) {
121
+ const lambda = armLengths[i] / dist;
122
+ positions[i + 1].x = positions[i].x + dx * lambda;
123
+ positions[i + 1].y = positions[i].y + dy * lambda;
124
+ }
125
+ }
126
+
127
+ // Check convergence
128
+ const endDx = positions[numSegments].x - targetX;
129
+ const endDy = positions[numSegments].y - targetY;
130
+ const endDist = Math.sqrt(endDx * endDx + endDy * endDy);
131
+
132
+ if (endDist < tolerance) break;
133
+ }
134
+
135
+ // Calculate angles from positions
136
+ const angles = [];
137
+ for (let i = 0; i < numSegments; i++) {
138
+ const dx = positions[i + 1].x - positions[i].x;
139
+ const dy = positions[i + 1].y - positions[i].y;
140
+ angles.push(Math.atan2(dy, dx));
141
+ }
142
+
143
+ // Save positions for next iteration
144
+ prevPositions = positions;
145
+
146
+ return { angles, positions };
147
+ };
148
+
149
+ // Colors
150
+ const c0 = d3.rgb(78, 165, 183);
151
+ const c1 = d3.rgb(206, 192, 250);
152
+ const c2 = d3.rgb(232, 137, 171);
153
+ const interp01 = d3.interpolateRgb(c0, c1);
154
+ const interp12 = d3.interpolateRgb(c1, c2);
155
+ const colorFor = (v) => {
156
+ const t = Math.max(0, Math.min(1, v));
157
+ return t <= 0.5 ? interp01(t / 0.5) : interp12((t - 0.5) / 0.5);
158
+ };
159
+
160
+ const svg = d3.select(container).append('svg')
161
+ .attr('width', '100%')
162
+ .style('display', 'block')
163
+ .style('cursor', 'default');
164
+
165
+ const render = () => {
166
+ const width = container.clientWidth || 800;
167
+ const height = Math.max(260, Math.round(width / 3));
168
+ svg.attr('width', width).attr('height', height);
169
+
170
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
171
+ const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
172
+ const glowColor = isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)';
173
+
174
+ // Base position (left side, bottom)
175
+ const baseX = width * 0.15;
176
+ const baseY = height * 0.85;
177
+
178
+ // Max reach of arm (sum of all segments)
179
+ const maxReach = armLengths.reduce((a, b) => a + b, 0); // 300px
180
+
181
+ // Initialize object positions (only once) - on table surface, within reach
182
+ if (objects[0].x === 0) {
183
+ const tableY = baseY - 20; // Same as table surface
184
+
185
+ objects[0].x = baseX + 130;
186
+ objects[0].y = tableY - objects[0].size / 2 - 4;
187
+ objects[0].targetX = objects[0].x;
188
+ objects[0].targetY = objects[0].y;
189
+
190
+ objects[1].x = baseX + 200;
191
+ objects[1].y = tableY - objects[1].size / 2 - 4;
192
+ objects[1].targetX = objects[1].x;
193
+ objects[1].targetY = objects[1].y;
194
+
195
+ objects[2].x = baseX + 270;
196
+ objects[2].y = tableY - objects[2].size / 2 - 4;
197
+ objects[2].targetX = objects[2].x;
198
+ objects[2].targetY = objects[2].y;
199
+
200
+ currentX = baseX + 180;
201
+ currentY = baseY - 150;
202
+ }
203
+
204
+ // Task execution system
205
+ const task = tasks[currentTask % tasks.length];
206
+ taskProgress++;
207
+
208
+ if (taskProgress >= task.duration) {
209
+ taskProgress = 0;
210
+ currentTask++;
211
+ if (currentTask >= tasks.length) {
212
+ currentTask = 0;
213
+ // Reset objects to initial positions on table
214
+ const tableY = baseY - 20;
215
+
216
+ objects[0].x = baseX + 130;
217
+ objects[0].y = tableY - objects[0].size / 2 - 4;
218
+ objects[0].targetX = objects[0].x;
219
+ objects[0].targetY = objects[0].y;
220
+ objects[0].placed = false;
221
+
222
+ objects[1].x = baseX + 200;
223
+ objects[1].y = tableY - objects[1].size / 2 - 4;
224
+ objects[1].targetX = objects[1].x;
225
+ objects[1].targetY = objects[1].y;
226
+ objects[1].placed = false;
227
+
228
+ objects[2].x = baseX + 270;
229
+ objects[2].y = tableY - objects[2].size / 2 - 4;
230
+ objects[2].targetX = objects[2].x;
231
+ objects[2].targetY = objects[2].y;
232
+ objects[2].placed = false;
233
+
234
+ trail.length = 0;
235
+ }
236
+ }
237
+
238
+ const t = taskProgress / task.duration; // 0 to 1
239
+ // Easing function for smooth movement
240
+ const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
241
+ const smoothT = easeInOutCubic(t);
242
+
243
+ // Execute current task with smooth transitions
244
+ if (task.type === 'pick') {
245
+ const obj = objects.find(o => o.id === task.objectId);
246
+ if (obj) {
247
+ // Approach object smoothly - hover above it
248
+ targetX = obj.x;
249
+ targetY = obj.y - 20; // Hover well above object
250
+
251
+ // Smooth gripper closing
252
+ if (t > 0.6) {
253
+ gripperOpenness = Math.max(0, 1 - (t - 0.6) / 0.3);
254
+ if (t > 0.75 && gripperOpen) {
255
+ gripperOpen = false;
256
+ heldObject = obj;
257
+ }
258
+ } else {
259
+ gripperOpenness = 1.0;
260
+ }
261
+ }
262
+ } else if (task.type === 'move') {
263
+ // Move to placement position (within reach, above table)
264
+ const destX = baseX + 150 + task.x * 100; // Spread across reachable area
265
+ const destY = baseY - 80 - task.y * 60; // Stay above table, within reach
266
+ targetX = destX;
267
+ targetY = destY;
268
+ } else if (task.type === 'place') {
269
+ // Open gripper smoothly
270
+ if (t > 0.2) {
271
+ gripperOpenness = Math.min(1, (t - 0.2) / 0.4);
272
+ if (t > 0.4 && !gripperOpen) {
273
+ gripperOpen = true;
274
+ if (heldObject) {
275
+ heldObject.targetX = targetX;
276
+ heldObject.targetY = targetY + 20;
277
+ heldObject.placed = true;
278
+ heldObject = null;
279
+ }
280
+ }
281
+ }
282
+ } else if (task.type === 'return') {
283
+ targetX = baseX + 180;
284
+ targetY = baseY - 140;
285
+ gripperOpenness = 1.0;
286
+ } else if (task.type === 'idle') {
287
+ targetX = baseX + 180;
288
+ targetY = baseY - 140;
289
+ gripperOpenness = 1.0;
290
+ }
291
+
292
+ // Smooth interpolation of current position towards target
293
+ const lerpFactor = 0.12; // Smoothing factor
294
+ currentX += (targetX - currentX) * lerpFactor;
295
+ currentY += (targetY - currentY) * lerpFactor;
296
+
297
+ // Solve IK using smoothed position
298
+ const { angles, positions: joints } = solveIK(currentX, currentY, baseX, baseY);
299
+
300
+ // Update trail
301
+ const endEffector = joints[joints.length - 1];
302
+ trail.push({ x: endEffector.x, y: endEffector.y });
303
+ if (trail.length > trailLength) trail.shift();
304
+
305
+ // Smooth object positions towards their targets
306
+ objects.forEach(obj => {
307
+ const objLerpFactor = 0.15;
308
+ obj.x += (obj.targetX - obj.x) * objLerpFactor;
309
+ obj.y += (obj.targetY - obj.y) * objLerpFactor;
310
+ });
311
+
312
+ // Update held object position (stick to gripper)
313
+ if (heldObject) {
314
+ heldObject.x = endEffector.x;
315
+ heldObject.y = endEffector.y + 8;
316
+ }
317
+
318
+ // Ensure container can host tooltip
319
+ container.style.position = container.style.position || 'relative';
320
+ let tip = container.querySelector('.d3-tooltip');
321
+ let tipInner;
322
+ if (!tip) {
323
+ tip = document.createElement('div');
324
+ tip.className = 'd3-tooltip';
325
+ Object.assign(tip.style, {
326
+ position: 'absolute',
327
+ top: '0px',
328
+ left: '0px',
329
+ transform: 'translate(-9999px, -9999px)',
330
+ pointerEvents: 'none',
331
+ padding: '10px 12px',
332
+ borderRadius: '12px',
333
+ fontSize: '12px',
334
+ lineHeight: '1.35',
335
+ border: '1px solid var(--border-color)',
336
+ background: 'var(--surface-bg)',
337
+ color: 'var(--text-color)',
338
+ boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',
339
+ opacity: '0',
340
+ transition: 'opacity .12s ease',
341
+ backdropFilter: 'saturate(1.12) blur(8px)',
342
+ zIndex: '20'
343
+ });
344
+ tipInner = document.createElement('div');
345
+ tipInner.className = 'd3-tooltip__inner';
346
+ Object.assign(tipInner.style, {
347
+ textAlign: 'left',
348
+ display: 'flex',
349
+ flexDirection: 'column',
350
+ gap: '6px',
351
+ minWidth: '220px'
352
+ });
353
+ tip.appendChild(tipInner);
354
+ container.appendChild(tip);
355
+ } else {
356
+ tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
357
+ }
358
+
359
+ // Draw workspace table/surface
360
+ const workspaceGroup = svg.selectAll('g.workspace').data([0]).join('g').attr('class', 'workspace');
361
+
362
+ // Table surface
363
+ workspaceGroup.selectAll('rect.table').data([0]).join('rect')
364
+ .attr('class', 'table')
365
+ .attr('x', baseX + 100)
366
+ .attr('y', baseY - 20)
367
+ .attr('width', 200)
368
+ .attr('height', 8)
369
+ .attr('fill', isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)')
370
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)')
371
+ .attr('stroke-width', 1)
372
+ .attr('rx', 2);
373
+
374
+ // Draw robot base/body
375
+ const bodyGroup = svg.selectAll('g.robot-body').data([0]).join('g').attr('class', 'robot-body');
376
+
377
+ // Base platform
378
+ bodyGroup.selectAll('rect.base').data([0]).join('rect')
379
+ .attr('class', 'base')
380
+ .attr('x', baseX - 60)
381
+ .attr('y', baseY - 10)
382
+ .attr('width', 120)
383
+ .attr('height', 30)
384
+ .attr('fill', colorFor(0.1))
385
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)')
386
+ .attr('stroke-width', 2)
387
+ .attr('rx', 4);
388
+
389
+ // Vertical pillar
390
+ bodyGroup.selectAll('rect.pillar').data([0]).join('rect')
391
+ .attr('class', 'pillar')
392
+ .attr('x', baseX - 15)
393
+ .attr('y', baseY - 40)
394
+ .attr('width', 30)
395
+ .attr('height', 40)
396
+ .attr('fill', colorFor(0.15))
397
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)')
398
+ .attr('stroke-width', 2)
399
+ .attr('rx', 3);
400
+
401
+ // Draw trail (lighter, behind everything)
402
+ const trailGroup = svg.selectAll('g.trail').data([0]).join('g').attr('class', 'trail');
403
+ const trailPath = d3.line()
404
+ .x(d => d.x)
405
+ .y(d => d.y)
406
+ .curve(d3.curveCatmullRom.alpha(0.5));
407
+
408
+ trailGroup.selectAll('path').data([trail]).join('path')
409
+ .attr('d', trailPath)
410
+ .attr('fill', 'none')
411
+ .attr('stroke', colorFor(0.7))
412
+ .attr('stroke-width', 1.5)
413
+ .attr('stroke-opacity', 0.25)
414
+ .attr('stroke-linecap', 'round');
415
+
416
+ // Draw arm segments
417
+ const armGroup = svg.selectAll('g.arm').data([0]).join('g').attr('class', 'arm');
418
+
419
+ armGroup.selectAll('line.segment').data(d3.range(numSegments)).join('line')
420
+ .attr('class', 'segment')
421
+ .attr('x1', i => joints[i].x)
422
+ .attr('y1', i => joints[i].y)
423
+ .attr('x2', i => joints[i + 1].x)
424
+ .attr('y2', i => joints[i + 1].y)
425
+ .attr('stroke', (d, i) => colorFor(i / (numSegments - 1)))
426
+ .attr('stroke-width', (d, i) => 8 - i * 1.5)
427
+ .attr('stroke-linecap', 'round')
428
+ .attr('stroke-opacity', 0.9);
429
+
430
+ // Draw objects to manipulate
431
+ const objectsGroup = svg.selectAll('g.objects').data([0]).join('g').attr('class', 'objects');
432
+
433
+ objectsGroup.selectAll('.object').data(objects).join(
434
+ enter => {
435
+ const g = enter.append('g').attr('class', 'object');
436
+ g.each(function(d) {
437
+ const elem = d3.select(this);
438
+ if (d.shape === 'square') {
439
+ elem.append('rect')
440
+ .attr('class', 'obj-shape')
441
+ .attr('width', d.size)
442
+ .attr('height', d.size)
443
+ .attr('rx', 3);
444
+ } else {
445
+ elem.append('circle')
446
+ .attr('class', 'obj-shape')
447
+ .attr('r', d.size / 2);
448
+ }
449
+ });
450
+ return g;
451
+ },
452
+ update => update
453
+ )
454
+ .attr('transform', d => `translate(${d.x}, ${d.y})`)
455
+ .style('cursor', 'pointer')
456
+ .each(function(d) {
457
+ const shape = d3.select(this).select('.obj-shape');
458
+ if (d.shape === 'square') {
459
+ shape
460
+ .attr('x', -d.size / 2)
461
+ .attr('y', -d.size / 2)
462
+ .attr('fill', colorFor(d.color))
463
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)')
464
+ .attr('stroke-width', 2);
465
+ } else {
466
+ shape
467
+ .attr('fill', colorFor(d.color))
468
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)')
469
+ .attr('stroke-width', 2);
470
+ }
471
+ })
472
+ .on('mouseenter', function(ev, d) {
473
+ d3.select(this).select('.obj-shape')
474
+ .transition().duration(120)
475
+ .attr('transform', 'scale(1.1)');
476
+ tipInner.innerHTML =
477
+ `<div style="font-weight:800;"><strong>${d.label}</strong></div>` +
478
+ `<div style="font-size:11px;color:var(--muted-color);margin-top:-2px;">Object ${d.shape === 'square' ? '(Cube)' : '(Sphere)'}</div>` +
479
+ `<div style="padding-top:4px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${d.x.toFixed(0)} · Y ${d.y.toFixed(0)}</div>` +
480
+ `<div><strong>Status</strong> ${heldObject === d ? 'Grasped' : 'On table'}</div>`;
481
+ tip.style.opacity = '1';
482
+ })
483
+ .on('mousemove', (ev) => {
484
+ const [mx, my] = d3.pointer(ev, container);
485
+ tip.style.transform = `translate(${Math.round(mx + 10)}px, ${Math.round(my + 12)}px)`;
486
+ })
487
+ .on('mouseleave', function() {
488
+ d3.select(this).select('.obj-shape')
489
+ .transition().duration(120)
490
+ .attr('transform', 'scale(1)');
491
+ tip.style.opacity = '0';
492
+ tip.style.transform = 'translate(-9999px, -9999px)';
493
+ });
494
+
495
+ // Draw gripper (pincers at end effector) with smooth animation
496
+ const gripperGroup = svg.selectAll('g.gripper').data([0]).join('g').attr('class', 'gripper');
497
+ const gripperAngle = angles[numSegments - 1];
498
+ const gripperSize = 8 + gripperOpenness * 10; // 8px (closed) to 18px (open)
499
+
500
+ gripperGroup.selectAll('line.gripper-jaw').data([1, -1]).join('line')
501
+ .attr('class', 'gripper-jaw')
502
+ .attr('x1', endEffector.x)
503
+ .attr('y1', endEffector.y)
504
+ .attr('x2', d => endEffector.x + Math.cos(gripperAngle + Math.PI / 2 * d) * gripperSize)
505
+ .attr('y2', d => endEffector.y + Math.sin(gripperAngle + Math.PI / 2 * d) * gripperSize)
506
+ .attr('stroke', colorFor(0.9))
507
+ .attr('stroke-width', 4)
508
+ .attr('stroke-linecap', 'round')
509
+ .style('transition', 'all 0.1s ease');
510
+
511
+ // Draw joints
512
+ const jointGroup = svg.selectAll('g.joints').data([0]).join('g').attr('class', 'joints');
513
+
514
+ jointGroup.selectAll('circle.joint').data(joints.slice(0, -1)).join('circle') // Don't draw last joint (gripper is there)
515
+ .attr('class', 'joint')
516
+ .attr('cx', d => d.x)
517
+ .attr('cy', d => d.y)
518
+ .attr('r', (d, i) => i === 0 ? 12 : (i === joints.length - 1 ? 10 : 8))
519
+ .attr('fill', (d, i) => i === 0 ? colorFor(0) : (i === joints.length - 1 ? colorFor(1) : colorFor(i / joints.length)))
520
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)')
521
+ .attr('stroke-width', 2)
522
+ .style('cursor', 'pointer')
523
+ .on('mouseenter', function(ev, d, i) {
524
+ const idx = joints.indexOf(d);
525
+ d3.select(this)
526
+ .raise()
527
+ .style('filter', `drop-shadow(0 0 12px ${glowColor})`)
528
+ .transition().duration(120).ease(d3.easeCubicOut)
529
+ .attr('r', (idx === 0 ? 12 : (idx === joints.length - 1 ? 10 : 8)) * 1.3)
530
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')
531
+ .attr('stroke-width', 3);
532
+
533
+ const jointName = idx === 0 ? 'Base Joint' : `Joint ${idx}`;
534
+ const angle = idx > 0 ? angles[idx - 1] * (180 / Math.PI) : 0;
535
+
536
+ tipInner.innerHTML =
537
+ `<div style="font-weight:800;letter-spacing:.1px;"><strong>${jointName}</strong></div>` +
538
+ `<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;">Robot Arm Joint</div>` +
539
+ `<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${d.x.toFixed(1)} · <strong>Y</strong> ${d.y.toFixed(1)}</div>` +
540
+ (idx > 0 ? `<div><strong>Angle</strong> ${angle.toFixed(1)}°</div>` : '') +
541
+ `<div><strong>Current Task</strong> ${task.type}</div>`;
542
+ tip.style.opacity = '1';
543
+ })
544
+ .on('mousemove', (ev) => {
545
+ const [mx, my] = d3.pointer(ev, container);
546
+ tip.style.transform = `translate(${Math.round(mx + 10)}px, ${Math.round(my + 12)}px)`;
547
+ })
548
+ .on('mouseleave', function(ev, d) {
549
+ const idx = joints.indexOf(d);
550
+ tip.style.opacity = '0';
551
+ tip.style.transform = 'translate(-9999px, -9999px)';
552
+ d3.select(this)
553
+ .style('filter', null)
554
+ .transition().duration(120).ease(d3.easeCubicOut)
555
+ .attr('r', idx === 0 ? 12 : (idx === joints.length - 1 ? 10 : 8))
556
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)')
557
+ .attr('stroke-width', 2);
558
+ });
559
+
560
+ // Draw task status label
561
+ const statusGroup = svg.selectAll('g.status').data([0]).join('g').attr('class', 'status');
562
+
563
+ const taskText = task.type === 'pick' ? `Picking ${objects.find(o => o.id === task.objectId)?.label}` :
564
+ task.type === 'move' ? 'Moving object' :
565
+ task.type === 'place' ? 'Placing object' :
566
+ task.type === 'return' ? 'Returning to home' : 'Idle';
567
+
568
+ statusGroup.selectAll('text.task-label').data([taskText]).join('text')
569
+ .attr('class', 'task-label')
570
+ .attr('x', width - 20)
571
+ .attr('y', 30)
572
+ .attr('text-anchor', 'end')
573
+ .attr('font-size', '14px')
574
+ .attr('font-weight', '600')
575
+ .attr('fill', colorFor(0.5))
576
+ .attr('opacity', 0.8)
577
+ .text(d => d);
578
+
579
+ // Draw progress bar
580
+ statusGroup.selectAll('rect.progress-bg').data([0]).join('rect')
581
+ .attr('class', 'progress-bg')
582
+ .attr('x', width - 200)
583
+ .attr('y', 40)
584
+ .attr('width', 180)
585
+ .attr('height', 6)
586
+ .attr('fill', isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)')
587
+ .attr('rx', 3);
588
+
589
+ statusGroup.selectAll('rect.progress-fill').data([0]).join('rect')
590
+ .attr('class', 'progress-fill')
591
+ .attr('x', width - 200)
592
+ .attr('y', 40)
593
+ .attr('width', 180 * t)
594
+ .attr('height', 6)
595
+ .attr('fill', colorFor(0.5))
596
+ .attr('rx', 3);
597
+ };
598
+
599
+ // Animation loop
600
+ let animationFrame;
601
+ const animate = () => {
602
+ render();
603
+ animationFrame = requestAnimationFrame(animate);
604
+ };
605
+
606
+ // Resize handling
607
+ if (window.ResizeObserver) {
608
+ const ro = new ResizeObserver(() => {
609
+ // render() is already called in animate loop
610
+ });
611
+ ro.observe(container);
612
+ } else {
613
+ window.addEventListener('resize', () => {
614
+ // render() is already called in animate loop
615
+ });
616
+ }
617
+
618
+ // Start animation
619
+ animate();
620
+
621
+ // Cleanup on unmount
622
+ const cleanup = () => {
623
+ if (animationFrame) cancelAnimationFrame(animationFrame);
624
+ };
625
+ if (container.dataset) container.dataset.cleanup = cleanup;
626
+ };
627
+
628
+ if (document.readyState === 'loading') {
629
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
630
+ } else { ensureD3(bootstrap); }
631
+ })();
632
+ </script>
633
+
app/src/content/{embeds2 → embeds}/d3-bar.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-benchmark.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-confusion-matrix.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-evals-after-fix.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-evals-tpbug.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-line-quad.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-line.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-matrix.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-neural-network.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-pie-quad.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-pie.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/d3-scatter.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/demo/color-picker.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/demo/content-structure.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/demo/palettes.html RENAMED
File without changes
app/src/content/{embeds2 → embeds}/original_embeds/plotly/banner.py RENAMED
File without changes
app/src/content/{embeds2 → embeds}/original_embeds/plotly/bar.py RENAMED
File without changes
app/src/content/{embeds2 → embeds}/original_embeds/plotly/heatmap.py RENAMED
File without changes
app/src/content/{embeds2 → embeds}/original_embeds/plotly/line.py RENAMED
File without changes
app/src/content/{embeds2 → embeds}/original_embeds/plotly/poetry.lock RENAMED
File without changes