pandorga 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (376) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +77 -0
  3. data/DESIGN.md +1434 -0
  4. data/LICENSE +21 -0
  5. data/README.md +81 -0
  6. data/_data/themes/dark.yml +97 -0
  7. data/_data/themes/light.yml +120 -0
  8. data/_data/themes/shared.yml +37 -0
  9. data/_data/translations.yml +85 -0
  10. data/_includes/chord-tab.html +38 -0
  11. data/_includes/content-runtime/00-config.html +22 -0
  12. data/_includes/content-runtime/10-utils.html +176 -0
  13. data/_includes/content-runtime/20-markdown.html +264 -0
  14. data/_includes/content-runtime/25-code-blocks.html +315 -0
  15. data/_includes/content-runtime/30-includes-bib.html +190 -0
  16. data/_includes/content-runtime/30-includes-xcite.html +122 -0
  17. data/_includes/content-runtime/30-includes.html +365 -0
  18. data/_includes/content-runtime/40-listing.html +225 -0
  19. data/_includes/content-runtime/41-listing-helpers.html +71 -0
  20. data/_includes/content-runtime/42-listing-filter.html +208 -0
  21. data/_includes/content-runtime/43-blocks.html +379 -0
  22. data/_includes/content-runtime/50-detail.html +861 -0
  23. data/_includes/content-runtime/55-toc.html +176 -0
  24. data/_includes/content-runtime/60-fragments.html +567 -0
  25. data/_includes/content-runtime/90-bootstrap.html +76 -0
  26. data/_includes/cut-in.html +10 -0
  27. data/_includes/cv/cv-box.html +18 -0
  28. data/_includes/dict-card.html +24 -0
  29. data/_includes/figure.html +36 -0
  30. data/_includes/home/band-index.html +43 -0
  31. data/_includes/home/band-listing.html +49 -0
  32. data/_includes/mathjax.html +10 -0
  33. data/_includes/ongoing-product.html +1 -0
  34. data/_includes/page/content-runtime.html +20 -0
  35. data/_includes/page/date_evolution.html +19 -0
  36. data/_includes/page/figure-lightbox.html +102 -0
  37. data/_includes/page/formatted_date.html +15 -0
  38. data/_includes/page/hero-default.html +18 -0
  39. data/_includes/page/listing-runtime-attrs.html +8 -0
  40. data/_includes/page/mermaid.html +101 -0
  41. data/_includes/page/network-graph.html +1253 -0
  42. data/_includes/page/page-header.html +41 -0
  43. data/_includes/page/professional-connections.html +2 -0
  44. data/_includes/page/site-footer.html +101 -0
  45. data/_includes/page/timeline-section.html +868 -0
  46. data/_includes/plotly.html +22 -0
  47. data/_includes/theme/dev-reexport.html +100 -0
  48. data/_includes/theme/favicons.html +4 -0
  49. data/_includes/theme/font-loader.html +55 -0
  50. data/_includes/theme/shell-theme-boot.html +115 -0
  51. data/_includes/theme/site-logo.html +6 -0
  52. data/_includes/theme/tailwind-theme-colors.json.liquid +65 -0
  53. data/_includes/theme/tailwind-theme-fonts.json.liquid +6 -0
  54. data/_includes/theme/theme-context.liquid +20 -0
  55. data/_includes/theme/theme-toggle.html +94 -0
  56. data/_includes/theme/theme-vars-block.liquid +161 -0
  57. data/_includes/theme/theme-vars.html +16 -0
  58. data/_includes/transcription.html +80 -0
  59. data/_includes/tweet.html +22 -0
  60. data/_includes/video.html +11 -0
  61. data/_includes/xcite.html +3 -0
  62. data/_includes/youtube-short.html +26 -0
  63. data/_includes/youtube.html +12 -0
  64. data/_layouts/article.html +27 -0
  65. data/_layouts/articles.html +534 -0
  66. data/_layouts/bibliography.html +417 -0
  67. data/_layouts/blog.html +544 -0
  68. data/_layouts/cv.html +393 -0
  69. data/_layouts/home.html +319 -0
  70. data/_layouts/network.html +9 -0
  71. data/_layouts/pandorga/articles.html +4 -0
  72. data/_layouts/pandorga/bibliography.html +4 -0
  73. data/_layouts/pandorga/blog.html +4 -0
  74. data/_layouts/pandorga/cv.html +4 -0
  75. data/_layouts/pandorga/media.html +4 -0
  76. data/_layouts/pandorga/network.html +4 -0
  77. data/_layouts/pandorga/portfolio.html +4 -0
  78. data/_layouts/post.html +7 -0
  79. data/_layouts/product.html +5 -0
  80. data/_layouts/project.html +27 -0
  81. data/_layouts/projects.html +534 -0
  82. data/_layouts/resources.html +387 -0
  83. data/_layouts/shell.html +203 -0
  84. data/_layouts/text.html +246 -0
  85. data/_plugins/bibliography_artifact.rb +57 -0
  86. data/_plugins/content_backend.rb +37 -0
  87. data/_plugins/content_collections.rb +51 -0
  88. data/_plugins/content_detail_paths.rb +58 -0
  89. data/_plugins/content_headers.rb +73 -0
  90. data/_plugins/content_keys.rb +310 -0
  91. data/_plugins/content_local_serve.rb +168 -0
  92. data/_plugins/content_site_adapter.rb +159 -0
  93. data/_plugins/content_validators_hook.rb +20 -0
  94. data/_plugins/content_writing_index.rb +52 -0
  95. data/_plugins/content_writing_paths.rb +55 -0
  96. data/_plugins/force_utf8_encoding.rb +13 -0
  97. data/_plugins/git_chronology.rb +211 -0
  98. data/_plugins/git_metadata.rb +43 -0
  99. data/_plugins/job_sections.rb +25 -0
  100. data/_plugins/lib/content_validators.rb +27 -0
  101. data/_plugins/lib/resource_validator.rb +56 -0
  102. data/_plugins/lib/tag_validator.rb +153 -0
  103. data/_plugins/lib/text_excerpt.rb +56 -0
  104. data/_plugins/lib/writing_entry.rb +107 -0
  105. data/_plugins/local_detail_rewrites.rb +194 -0
  106. data/_plugins/timeline.rb +507 -0
  107. data/_plugins/writing_slug_limits.rb +70 -0
  108. data/assets/css/article.css +1330 -0
  109. data/assets/css/base.css +1432 -0
  110. data/assets/css/cv.css +13 -0
  111. data/assets/css/global-link.css +94 -0
  112. data/assets/css/ledger/bibliography.css +307 -0
  113. data/assets/css/ledger/chrome.css +118 -0
  114. data/assets/css/ledger/cv.css +836 -0
  115. data/assets/css/ledger/detail.css +1270 -0
  116. data/assets/css/ledger/footer.css +298 -0
  117. data/assets/css/ledger/home.css +1490 -0
  118. data/assets/css/ledger/network.css +106 -0
  119. data/assets/css/ledger/projects.css +716 -0
  120. data/assets/css/ledger/rascunhos.css +379 -0
  121. data/assets/css/ledger/sources.css +327 -0
  122. data/assets/css/ledger/writing.css +754 -0
  123. data/assets/tailwind-play.js +15 -0
  124. data/examples/full/Gemfile +7 -0
  125. data/examples/full/_config.yml +92 -0
  126. data/examples/full/_redirects +3 -0
  127. data/examples/full/content/collections/articles/2026-01-01-welcome.md +9 -0
  128. data/examples/full/content/collections/data/contacts.yml +3 -0
  129. data/examples/full/content/collections/data/education.yml +4 -0
  130. data/examples/full/content/collections/jobs/research-fellow.md +12 -0
  131. data/examples/full/content/collections/posts/2026-01-02-note.md +9 -0
  132. data/examples/full/content/collections/products/ledger-tool.md +6 -0
  133. data/examples/full/content/collections/projects/demo-kit.md +10 -0
  134. data/examples/full/content/collections/resources/style-guide.md +7 -0
  135. data/examples/full/content/pages/cv/summary.md +1 -0
  136. data/examples/full/content/pages/headers.yml +20 -0
  137. data/examples/full/index.html +4 -0
  138. data/examples/minimal/Gemfile +7 -0
  139. data/examples/minimal/_config.yml +47 -0
  140. data/examples/minimal/_redirects +3 -0
  141. data/examples/minimal/content/collections/articles/2026-01-01-hello.md +10 -0
  142. data/examples/minimal/content/collections/articles/2026-01-02-second.md +9 -0
  143. data/examples/minimal/content/pages/headers.yml +6 -0
  144. data/examples/minimal/index.html +4 -0
  145. data/exe/pandorga +7 -0
  146. data/functions/api/studio/[[path]].js +562 -0
  147. data/functions/api/studio/_lib/auth.js +131 -0
  148. data/functions/api/studio/_lib/blocks.js +156 -0
  149. data/functions/api/studio/_lib/gemini.js +62 -0
  150. data/functions/api/studio/_lib/github.js +278 -0
  151. data/functions/api/studio/_lib/guidelines.js +143 -0
  152. data/functions/api/studio/_lib/paths.js +76 -0
  153. data/functions/api/studio/_lib/prompts.js +165 -0
  154. data/functions/api/studio/_lib/rate-limit.js +40 -0
  155. data/functions/api/studio/_lib/rewrite.js +136 -0
  156. data/functions/api/studio/_lib/sanitize.js +143 -0
  157. data/lib/pandorga/cli.rb +66 -0
  158. data/lib/pandorga/commands/doctor.rb +66 -0
  159. data/lib/pandorga/commands/export.rb +30 -0
  160. data/lib/pandorga/commands/install_functions.rb +41 -0
  161. data/lib/pandorga/commands/new.rb +113 -0
  162. data/lib/pandorga/commands/publish.rb +53 -0
  163. data/lib/pandorga/commands/serve.rb +34 -0
  164. data/lib/pandorga/commands/studio_schema.rb +309 -0
  165. data/lib/pandorga/jekyll/generator.rb +74 -0
  166. data/lib/pandorga/jekyll/plugins/bibliography_artifact.rb +57 -0
  167. data/lib/pandorga/jekyll/plugins/content_backend.rb +37 -0
  168. data/lib/pandorga/jekyll/plugins/content_collections.rb +51 -0
  169. data/lib/pandorga/jekyll/plugins/content_detail_paths.rb +58 -0
  170. data/lib/pandorga/jekyll/plugins/content_headers.rb +73 -0
  171. data/lib/pandorga/jekyll/plugins/content_keys.rb +310 -0
  172. data/lib/pandorga/jekyll/plugins/content_local_serve.rb +168 -0
  173. data/lib/pandorga/jekyll/plugins/content_site_adapter.rb +159 -0
  174. data/lib/pandorga/jekyll/plugins/content_validators_hook.rb +20 -0
  175. data/lib/pandorga/jekyll/plugins/content_writing_index.rb +52 -0
  176. data/lib/pandorga/jekyll/plugins/content_writing_paths.rb +55 -0
  177. data/lib/pandorga/jekyll/plugins/force_utf8_encoding.rb +13 -0
  178. data/lib/pandorga/jekyll/plugins/git_chronology.rb +211 -0
  179. data/lib/pandorga/jekyll/plugins/git_metadata.rb +43 -0
  180. data/lib/pandorga/jekyll/plugins/job_sections.rb +25 -0
  181. data/lib/pandorga/jekyll/plugins/lib/content_validators.rb +27 -0
  182. data/lib/pandorga/jekyll/plugins/lib/resource_validator.rb +56 -0
  183. data/lib/pandorga/jekyll/plugins/lib/tag_validator.rb +153 -0
  184. data/lib/pandorga/jekyll/plugins/lib/text_excerpt.rb +56 -0
  185. data/lib/pandorga/jekyll/plugins/lib/writing_entry.rb +107 -0
  186. data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +194 -0
  187. data/lib/pandorga/jekyll/plugins/timeline.rb +507 -0
  188. data/lib/pandorga/jekyll/plugins/writing_slug_limits.rb +70 -0
  189. data/lib/pandorga/jekyll.rb +15 -0
  190. data/lib/pandorga/object_store_env.rb +51 -0
  191. data/lib/pandorga/registry/schema.yml +115 -0
  192. data/lib/pandorga/registry.rb +279 -0
  193. data/lib/pandorga/version.rb +5 -0
  194. data/lib/pandorga.rb +19 -0
  195. data/scripts/cdn-allowlist.json +19 -0
  196. data/scripts/content/backfill-content-keys.rb +99 -0
  197. data/scripts/content/band_art.rb +362 -0
  198. data/scripts/content/export-content-json.rb +741 -0
  199. data/scripts/content/flatten-ansi-capture.mjs +153 -0
  200. data/scripts/content/hero_portraits.rb +28 -0
  201. data/scripts/content/publish-content-to-object-store.sh +78 -0
  202. data/scripts/content/publish-content-to-r2.sh +5 -0
  203. data/scripts/content/sync-content-json-to-r2.rb +167 -0
  204. data/scripts/content/sync-content-redirects.rb +12 -0
  205. data/scripts/generate-configuration-doc.rb +127 -0
  206. data/scripts/lib/content_license.rb +50 -0
  207. data/scripts/lib/content_storage_config.rb +70 -0
  208. data/scripts/lib/normalize-display-math.mjs +52 -0
  209. data/scripts/lib/r2_json_sync.rb +76 -0
  210. data/scripts/lib/withdrawn_public_objects.rb +23 -0
  211. data/scripts/lib/writing_index_contract.rb +61 -0
  212. data/scripts/pdf/md-to-pdf.mjs +1400 -0
  213. data/scripts/pdf/md-to-pdf.sh +27 -0
  214. data/scripts/test/fixtures/dual-template/_config.yml +32 -0
  215. data/scripts/test/fixtures/dual-template/index.html +4 -0
  216. data/scripts/test/render-parity/article-note.json +5 -0
  217. data/scripts/test/render-parity/blockquote.json +5 -0
  218. data/scripts/test/render-parity/blocks/ansi-basic.json +7 -0
  219. data/scripts/test/render-parity/blocks/ansi-bold-is-bright.json +7 -0
  220. data/scripts/test/render-parity/blocks/ansi-drops-cursor-moves.json +7 -0
  221. data/scripts/test/render-parity/blocks/ansi-escapes-markup.json +7 -0
  222. data/scripts/test/render-parity/blocks/ansi-extended-colour.json +7 -0
  223. data/scripts/test/render-parity/blocks/ansi-real-control-byte.json +7 -0
  224. data/scripts/test/render-parity/blocks/archive-card-date-once.json +19 -0
  225. data/scripts/test/render-parity/blocks/archive-card-empty-foot.json +7 -0
  226. data/scripts/test/render-parity/blocks/archive-card-footer-dates.json +22 -0
  227. data/scripts/test/render-parity/blocks/archive-card-meta-parts.json +26 -0
  228. data/scripts/test/render-parity/blocks/archive-card.json +32 -0
  229. data/scripts/test/render-parity/blocks/cta-link.json +13 -0
  230. data/scripts/test/render-parity/blocks/lang-pill-pt.json +8 -0
  231. data/scripts/test/render-parity/blocks/lang-pill.json +8 -0
  232. data/scripts/test/render-parity/blocks/ledger-row-meta-parts.json +29 -0
  233. data/scripts/test/render-parity/blocks/ledger-row.json +35 -0
  234. data/scripts/test/render-parity/blocks/meta-strip.json +27 -0
  235. data/scripts/test/render-parity/blocks/poster-media-image.json +8 -0
  236. data/scripts/test/render-parity/blocks/poster-media-video.json +9 -0
  237. data/scripts/test/render-parity/blocks/section-banner.json +13 -0
  238. data/scripts/test/render-parity/blocks/system-card-custom-meta.json +22 -0
  239. data/scripts/test/render-parity/blocks/system-card.json +48 -0
  240. data/scripts/test/render-parity/blocks/tag-chips-links.json +12 -0
  241. data/scripts/test/render-parity/blocks/tag-chips.json +9 -0
  242. data/scripts/test/render-parity/cut-in-blank-line.json +9 -0
  243. data/scripts/test/render-parity/cut-in-right.json +9 -0
  244. data/scripts/test/render-parity/cut-in.json +8 -0
  245. data/scripts/test/render-parity/figure.json +5 -0
  246. data/scripts/test/render-parity/load-runtime.mjs +337 -0
  247. data/scripts/test/render-parity/ongoing-product.json +5 -0
  248. data/scripts/test/render-parity/plotly.json +5 -0
  249. data/scripts/test/render-parity/poem.json +5 -0
  250. data/scripts/test/render-parity/run-absent-field-checks.mjs +232 -0
  251. data/scripts/test/render-parity/run-block-fixture.mjs +86 -0
  252. data/scripts/test/render-parity/run-detail-parity-checks.mjs +123 -0
  253. data/scripts/test/render-parity/run-field-flow-checks.mjs +227 -0
  254. data/scripts/test/render-parity/run-fixture.mjs +159 -0
  255. data/scripts/test/render-parity/selectors.mjs +43 -0
  256. data/scripts/test/render-parity/video.json +5 -0
  257. data/scripts/test/render-parity/youtube-short.json +10 -0
  258. data/scripts/test/render-parity/youtube.json +8 -0
  259. data/scripts/test/test-absent-field-absent-element.rb +43 -0
  260. data/scripts/test/test-cdn-pins.rb +44 -0
  261. data/scripts/test/test-cloudflare-watch-paths-doc.rb +31 -0
  262. data/scripts/test/test-configuration-doc.rb +36 -0
  263. data/scripts/test/test-content-detail-urls.rb +346 -0
  264. data/scripts/test/test-content-keys.rb +110 -0
  265. data/scripts/test/test-content-license.rb +45 -0
  266. data/scripts/test/test-content-render-parity.rb +38 -0
  267. data/scripts/test/test-content-validators.rb +12 -0
  268. data/scripts/test/test-css-structure.rb +72 -0
  269. data/scripts/test/test-cv-export-contract.rb +158 -0
  270. data/scripts/test/test-detail-render-parity.rb +40 -0
  271. data/scripts/test/test-dev-reexport.rb +122 -0
  272. data/scripts/test/test-doctor.rb +39 -0
  273. data/scripts/test/test-dual-template-instances.rb +87 -0
  274. data/scripts/test/test-dual-template-runtime.rb +167 -0
  275. data/scripts/test/test-examples-build.rb +60 -0
  276. data/scripts/test/test-export-content-json-incremental.rb +138 -0
  277. data/scripts/test/test-export.rb +53 -0
  278. data/scripts/test/test-exported-fields-reach-the-view.rb +60 -0
  279. data/scripts/test/test-form-control-theming.rb +143 -0
  280. data/scripts/test/test-git-chronology.rb +414 -0
  281. data/scripts/test/test-hero-portraits.mjs +97 -0
  282. data/scripts/test/test-hero-portraits.rb +162 -0
  283. data/scripts/test/test-home-band-art.rb +135 -0
  284. data/scripts/test/test-install-functions.rb +39 -0
  285. data/scripts/test/test-language-export.rb +79 -0
  286. data/scripts/test/test-listing-filter-contract.rb +119 -0
  287. data/scripts/test/test-math-row-breaks.mjs +79 -0
  288. data/scripts/test/test-math-row-breaks.rb +18 -0
  289. data/scripts/test/test-new.rb +113 -0
  290. data/scripts/test/test-no-personal-data.rb +85 -0
  291. data/scripts/test/test-public-cv-export.rb +164 -0
  292. data/scripts/test/test-r2-config-consistency.rb +57 -0
  293. data/scripts/test/test-r2-json-sync.rb +193 -0
  294. data/scripts/test/test-registry-home.rb +34 -0
  295. data/scripts/test/test-runtime-js-syntax.rb +64 -0
  296. data/scripts/test/test-slim-writing-index.rb +126 -0
  297. data/scripts/test/test-studio-api-contract.rb +99 -0
  298. data/scripts/test/test-studio-content-files.rb +79 -0
  299. data/scripts/test/test-studio-liquid-roundtrip.mjs +179 -0
  300. data/scripts/test/test-studio-rate-limit.mjs +70 -0
  301. data/scripts/test/test-studio-rewrite-api-contract.rb +59 -0
  302. data/scripts/test/test-studio-rewrite-sanitize.mjs +210 -0
  303. data/scripts/test/test-studio-schema-composition.rb +126 -0
  304. data/scripts/test/test-studio-slug-config.rb +167 -0
  305. data/scripts/test/test-template-packages.rb +51 -0
  306. data/scripts/test/test-text-excerpt.rb +51 -0
  307. data/scripts/test/test-unwrap-media-src.mjs +39 -0
  308. data/scripts/test/test-unwrap-media-src.rb +19 -0
  309. data/scripts/test/test-withdrawn-content-export.rb +69 -0
  310. data/scripts/test/test-writing-index-export-slugs.rb +84 -0
  311. data/scripts/test/test-writing-slug-limits.rb +61 -0
  312. data/scripts/test/test-xcite-chronology.rb +106 -0
  313. data/scripts/validate.sh +74 -0
  314. data/scripts/visual/build-cv-pages-json.js +33 -0
  315. data/scripts/visual/build-site-pages-json.js +94 -0
  316. data/scripts/visual/publish-visual-inspection-to-r2.sh +42 -0
  317. data/scripts/visual/take-screenshots.js +88 -0
  318. data/scripts/visual/visual-inspection-pages.json +113 -0
  319. data/studio/assets/index-B_j0pMD5.css +1 -0
  320. data/studio/assets/index-Ch4YEv9g.js +285 -0
  321. data/studio/index.html +27 -0
  322. data/studio/schema.yml +869 -0
  323. data/studio-app/README.md +27 -0
  324. data/studio-app/index.html +26 -0
  325. data/studio-app/package-lock.json +2499 -0
  326. data/studio-app/package.json +26 -0
  327. data/studio-app/skills-lock.json +47 -0
  328. data/studio-app/src/api/client.js +71 -0
  329. data/studio-app/src/editor/body-editor.js +70 -0
  330. data/studio-app/src/editor/liquid-core.js +382 -0
  331. data/studio-app/src/editor/liquid.js +77 -0
  332. data/studio-app/src/lib/document.js +207 -0
  333. data/studio-app/src/lib/list-controls.js +317 -0
  334. data/studio-app/src/lib/schema.js +66 -0
  335. data/studio-app/src/lib/yaml-doc.js +100 -0
  336. data/studio-app/src/main.js +3693 -0
  337. data/studio-app/src/media/browser.js +578 -0
  338. data/studio-app/src/preview/render.js +430 -0
  339. data/studio-app/src/styles/studio.css +2826 -0
  340. data/studio-app/vite.config.js +25 -0
  341. data/templates/articles/README.md +40 -0
  342. data/templates/articles/fixtures/article.md +9 -0
  343. data/templates/articles/screenshots/README.md +6 -0
  344. data/templates/articles/studio.yml +73 -0
  345. data/templates/articles/template.yml +21 -0
  346. data/templates/bibliography/README.md +41 -0
  347. data/templates/bibliography/fixtures/references.json +9 -0
  348. data/templates/bibliography/screenshots/README.md +6 -0
  349. data/templates/bibliography/studio.yml +66 -0
  350. data/templates/bibliography/template.yml +18 -0
  351. data/templates/blog/README.md +38 -0
  352. data/templates/blog/fixtures/post.md +9 -0
  353. data/templates/blog/screenshots/README.md +6 -0
  354. data/templates/blog/studio.yml +69 -0
  355. data/templates/blog/template.yml +20 -0
  356. data/templates/cv/README.md +34 -0
  357. data/templates/cv/fixtures/job.md +12 -0
  358. data/templates/cv/screenshots/README.md +6 -0
  359. data/templates/cv/studio.yml +200 -0
  360. data/templates/cv/template.yml +25 -0
  361. data/templates/media/README.md +38 -0
  362. data/templates/media/fixtures/resource.md +7 -0
  363. data/templates/media/screenshots/README.md +6 -0
  364. data/templates/media/studio.yml +58 -0
  365. data/templates/media/template.yml +17 -0
  366. data/templates/network/README.md +34 -0
  367. data/templates/network/fixtures/sources.json +3 -0
  368. data/templates/network/screenshots/README.md +6 -0
  369. data/templates/network/studio.yml +28 -0
  370. data/templates/network/template.yml +10 -0
  371. data/templates/portfolio/README.md +39 -0
  372. data/templates/portfolio/fixtures/project.md +10 -0
  373. data/templates/portfolio/screenshots/README.md +6 -0
  374. data/templates/portfolio/studio.yml +89 -0
  375. data/templates/portfolio/template.yml +18 -0
  376. metadata +505 -0
@@ -0,0 +1,868 @@
1
+ {% include theme/theme-context.liquid %}
2
+
3
+ <!-- Timeline. The applet owns its own left rail of controls, so it runs as
4
+ a full-width band rather than inside the register's title rail. -->
5
+ <section class="cv-band" id="timeline-section">
6
+ <div class="cv-band-head">
7
+ <h2 class="section-title">Timeline</h2>
8
+ </div>
9
+
10
+ <div class="cv-timeline-grid">
11
+ <div class="cv-timeline-rail">
12
+ <div class="cv-timeline-rail-inner">
13
+ <div class="cv-timeline-control-group">
14
+ <h3 class="cv-timeline-control-title card-meta">Lanes</h3>
15
+ <ul class="cv-timeline-options font-body">
16
+ <li><label class="cv-timeline-option"><input type="checkbox" class="timeline-lane-toggle" data-lane="career" checked> Career</label></li>
17
+ <li><label class="cv-timeline-option"><input type="checkbox" class="timeline-lane-toggle" data-lane="education" checked> Education</label></li>
18
+ <li><label class="cv-timeline-option"><input type="checkbox" class="timeline-lane-toggle" data-lane="personal" checked> Personal</label></li>
19
+ <li><label class="cv-timeline-option"><input type="checkbox" class="timeline-lane-toggle" data-lane="articles" checked> Articles</label></li>
20
+ <li><label class="cv-timeline-option"><input type="checkbox" class="timeline-lane-toggle" data-lane="posts" checked> Posts / Rascunhos</label></li>
21
+ </ul>
22
+ </div>
23
+
24
+ <div class="cv-timeline-control-group" id="timeline-selection-panel" hidden>
25
+ <h3 class="cv-timeline-control-title card-meta">Selected period</h3>
26
+ <p id="timeline-selection-range" class="cv-timeline-range code-technical"></p>
27
+ <div id="timeline-selection-education-wrap" hidden>
28
+ <h4 class="cv-timeline-control-subtitle card-meta">Education</h4>
29
+ <ul id="timeline-selection-education" class="cv-timeline-selection-list font-body"></ul>
30
+ </div>
31
+ <div id="timeline-selection-projects-wrap" hidden>
32
+ <h4 class="cv-timeline-control-subtitle card-meta">Projects</h4>
33
+ <ul id="timeline-selection-projects" class="cv-timeline-selection-list font-body"></ul>
34
+ </div>
35
+ <p id="timeline-selection-empty" class="cv-timeline-selection-empty font-body" hidden>No items in this period.</p>
36
+ <button type="button" id="timeline-selection-clear" class="cv-timeline-clear card-meta">Clear selection</button>
37
+ </div>
38
+
39
+ <div class="cv-timeline-control-group">
40
+ <h3 class="cv-timeline-control-title card-meta">Writing</h3>
41
+ <label class="cv-timeline-option font-body">
42
+ <input type="checkbox" id="timeline-writing-expanded"> Show each piece
43
+ </label>
44
+ </div>
45
+
46
+ <div class="cv-timeline-control-group">
47
+ <h3 class="cv-timeline-control-title card-meta">Legend</h3>
48
+ <ul class="cv-timeline-legend font-body">
49
+ <li><span class="cv-timeline-key cv-timeline-key--bar cv-timeline-key--employment"></span>Job</li>
50
+ <li><span class="cv-timeline-key cv-timeline-key--bar-thin cv-timeline-key--product"></span>Product</li>
51
+ <li><span class="cv-timeline-key cv-timeline-key--bar cv-timeline-key--education"></span>Education</li>
52
+ <li><span class="cv-timeline-key cv-timeline-key--bar-thin cv-timeline-key--project"></span>Project</li>
53
+ <li><span class="cv-timeline-key cv-timeline-key--dot cv-timeline-key--article"></span>Article</li>
54
+ <li><span class="cv-timeline-key cv-timeline-key--dot cv-timeline-key--post"></span>Post</li>
55
+ </ul>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <div class="cv-timeline-canvas">
61
+ <div id="timeline-chart-wrapper" class="applet-canvas relative w-full overflow-x-auto">
62
+ <div id="timeline-chart" class="relative"></div>
63
+ <div id="timeline-zoom-controls" class="timeline-applet-control cv-timeline-zoom sticky left-0 z-10 flex items-center gap-2 border px-2 py-1.5 w-fit ml-2 mt-4 mb-4 pointer-events-auto">
64
+ <button type="button" id="timeline-zoom-out" class="timeline-zoom-btn timeline-applet-control font-label text-sm leading-none w-8 h-8 border text-on-surface-variant hover:border-primary hover:text-primary transition-colors" aria-label="Zoom out">−</button>
65
+ <span id="timeline-zoom-label" class="cv-timeline-zoom-label code-technical min-w-[3.5rem] text-center">2 yr</span>
66
+ <button type="button" id="timeline-zoom-in" class="timeline-zoom-btn timeline-applet-control font-label text-sm leading-none w-8 h-8 border text-on-surface-variant hover:border-primary hover:text-primary transition-colors" aria-label="Zoom in">+</button>
67
+ </div>
68
+ <div id="timeline-tooltip" class="timeline-applet-control hidden absolute z-20 max-w-xs border px-3 py-2 text-sm font-body text-on-surface-variant"></div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </section>
73
+
74
+ <script type="application/json" id="timeline-data">{"meta":{},"events":[],"writing_buckets":{}}</script>
75
+
76
+ <style>
77
+ #timeline-chart .timeline-axis {
78
+ position: sticky;
79
+ top: 0;
80
+ z-index: 4;
81
+ background: var(--color-surface-container-low);
82
+ border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 35%, transparent);
83
+ }
84
+ #timeline-chart .timeline-axis-gutter,
85
+ #timeline-chart .timeline-lane-label {
86
+ position: sticky;
87
+ left: 0;
88
+ z-index: 5;
89
+ background: var(--color-surface-container-low);
90
+ border-right: 1px solid color-mix(in srgb, var(--color-outline-variant) 35%, transparent);
91
+ box-sizing: border-box;
92
+ flex-shrink: 0;
93
+ }
94
+ #timeline-chart .timeline-axis-gutter {
95
+ z-index: 6;
96
+ align-self: stretch;
97
+ }
98
+ #timeline-chart .timeline-lane-row {
99
+ border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
100
+ }
101
+ #timeline-chart .timeline-bar {
102
+ position: absolute;
103
+ border-radius: 0;
104
+ cursor: default;
105
+ transition: opacity var(--motion-duration-fast) var(--motion-ease-default), box-shadow var(--motion-duration-fast) var(--motion-ease-default);
106
+ opacity: 0.68;
107
+ box-sizing: border-box;
108
+ border: 1px solid color-mix(in srgb, var(--color-surface) 35%, transparent);
109
+ z-index: 2;
110
+ }
111
+ #timeline-chart .timeline-bar:hover,
112
+ #timeline-chart .timeline-bar.is-highlighted {
113
+ opacity: 0.95;
114
+ z-index: 3;
115
+ }
116
+ #timeline-chart .timeline-bar.is-dimmed {
117
+ opacity: 0.28;
118
+ }
119
+ #timeline-chart .timeline-bar.is-selected-range {
120
+ opacity: 0.95;
121
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-on-surface) 25%, transparent);
122
+ z-index: 2;
123
+ }
124
+ #timeline-chart .timeline-bar--ongoing::after {
125
+ content: '';
126
+ position: absolute;
127
+ top: 0;
128
+ right: 0;
129
+ width: 6px;
130
+ height: 100%;
131
+ background: repeating-linear-gradient(
132
+ -45deg,
133
+ transparent,
134
+ transparent 2px,
135
+ color-mix(in srgb, var(--color-surface) 55%, transparent) 2px,
136
+ color-mix(in srgb, var(--color-surface) 55%, transparent) 4px
137
+ );
138
+ }
139
+ #timeline-chart .timeline-density-bar {
140
+ position: absolute;
141
+ bottom: 4px;
142
+ cursor: default;
143
+ box-sizing: border-box;
144
+ }
145
+ /* The lane tokens, not --color-primary/--color-tertiary: those are the UI
146
+ triad, and on this page `.page-accent--green` rebinds the primary, so
147
+ the article bars used to come out the same green as the education lane. */
148
+ #timeline-chart .timeline-density-bar--article {
149
+ background: color-mix(in srgb, var(--timeline-article) 62%, transparent);
150
+ }
151
+ #timeline-chart .timeline-density-bar--post {
152
+ background: color-mix(in srgb, var(--timeline-post) 62%, transparent);
153
+ }
154
+ #timeline-chart .timeline-marker {
155
+ position: absolute;
156
+ width: 8px;
157
+ height: 8px;
158
+ border-radius: 50%;
159
+ transform: translate(-50%, -50%);
160
+ cursor: default;
161
+ opacity: 0.75;
162
+ }
163
+ #timeline-chart .timeline-selection-band {
164
+ position: absolute;
165
+ top: 0;
166
+ bottom: 0;
167
+ background: color-mix(in srgb, var(--color-tertiary) 12%, transparent);
168
+ border-left: 1px solid color-mix(in srgb, var(--color-tertiary) 45%, transparent);
169
+ border-right: 1px solid color-mix(in srgb, var(--color-tertiary) 45%, transparent);
170
+ pointer-events: none;
171
+ z-index: 1;
172
+ }
173
+ #timeline-chart .timeline-track-select {
174
+ cursor: crosshair;
175
+ }
176
+ </style>
177
+
178
+ <script>
179
+ (function () {
180
+ 'use strict';
181
+
182
+ var el = document.getElementById('timeline-data');
183
+
184
+ function bootTimeline(payload) {
185
+ var meta = payload.meta || {};
186
+ var events = payload.events || [];
187
+ var buckets = Array.isArray(payload.writing_buckets) ? payload.writing_buckets : [];
188
+
189
+ var LABEL_W = 96;
190
+ var AXIS_H = 36;
191
+ var MS_DAY = 86400000;
192
+ var MS_YEAR = 365.25 * MS_DAY;
193
+ var zoomYears = 2;
194
+ var MIN_ZOOM_YEARS = 0.25;
195
+ var SUBLANE_H = 20;
196
+ var SUBLANE_GAP = 3;
197
+ var SUBLANE_H_THIN = 13;
198
+ var SUBLANE_GAP_THIN = 1;
199
+ var LANE_PAD = 10;
200
+ var BASE_LANE_H = { career: 56, education: 36, personal: 36, articles: 48, posts: 48 };
201
+
202
+ var laneVisible = { career: true, education: true, personal: true, articles: true, posts: true };
203
+ var writingExpanded = false;
204
+ var selection = null;
205
+ var barRegistry = [];
206
+
207
+ /* Lane colours come from the live CSS custom properties, not from Liquid.
208
+ *
209
+ * Baking them at build time resolved `shell_theme.active: system` to no
210
+ * palette and fell back to light, so the chart drew the light triad on a
211
+ * near-black canvas in dark mode and the theme toggle had no effect on
212
+ * it. Reading the computed properties instead picks up whichever palette
213
+ * `data-theme` currently names, and `refreshColors()` on the toggle's
214
+ * event keeps it that way.
215
+ *
216
+ * They are also their own tokens rather than the UI triad, because six
217
+ * event types have to be distinguishable and the triad only holds three:
218
+ * employment and article used to share `primary`, product and post shared
219
+ * `tertiary`. On this page `--color-primary` is additionally rebound to
220
+ * green by `.page-accent--green`, which would have collapsed the chart
221
+ * further. A categorical scale is the right instrument for a chart.
222
+ */
223
+ var COLORS = {};
224
+
225
+ function refreshColors() {
226
+ var cs = getComputedStyle(document.documentElement);
227
+ function token(name, fallback) {
228
+ var v = cs.getPropertyValue(name);
229
+ v = v ? v.trim() : '';
230
+ return v || fallback;
231
+ }
232
+ COLORS.employment = token('--timeline-employment', '#1c5a7a');
233
+ COLORS.education = token('--timeline-education', '#287445');
234
+ COLORS.product = token('--timeline-product', '#8a6a30');
235
+ COLORS.project = token('--timeline-project', '#5c5896');
236
+ COLORS.article = token('--timeline-article', '#a0543a');
237
+ COLORS.post = token('--timeline-post', '#4f7d72');
238
+ }
239
+
240
+ refreshColors();
241
+
242
+ var container = document.getElementById('timeline-chart');
243
+ var tooltip = document.getElementById('timeline-tooltip');
244
+ var wrapper = document.getElementById('timeline-chart-wrapper');
245
+ var selectionPanel = document.getElementById('timeline-selection-panel');
246
+ var selectionRange = document.getElementById('timeline-selection-range');
247
+ var selectionProjects = document.getElementById('timeline-selection-projects');
248
+ var selectionProjectsWrap = document.getElementById('timeline-selection-projects-wrap');
249
+ var selectionEducation = document.getElementById('timeline-selection-education');
250
+ var selectionEducationWrap = document.getElementById('timeline-selection-education-wrap');
251
+ var selectionEmpty = document.getElementById('timeline-selection-empty');
252
+ var selectionClear = document.getElementById('timeline-selection-clear');
253
+ var zoomInBtn = document.getElementById('timeline-zoom-in');
254
+ var zoomOutBtn = document.getElementById('timeline-zoom-out');
255
+ var zoomLabel = document.getElementById('timeline-zoom-label');
256
+ var selectionLayer = null;
257
+ var selectionBand = null;
258
+ var dragState = { active: false, anchorMs: 0 };
259
+
260
+ function parseDate(s) {
261
+ var p = s.split('-');
262
+ return new Date(+p[0], +p[1] - 1, +p[2] || 1);
263
+ }
264
+
265
+ function formatMonth(d) {
266
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
267
+ }
268
+
269
+ function formatRange(ev) {
270
+ var rs = ev.meta && ev.meta.raw_start;
271
+ var re = ev.meta && ev.meta.raw_end;
272
+ if (ev.start === ev.end) return rs || ev.start;
273
+ var endLabel = ev.ongoing ? 'Present' : (re || ev.end.slice(0, 7));
274
+ return (rs || ev.start.slice(0, 7)) + ' – ' + endLabel;
275
+ }
276
+
277
+ var todayT = parseDate(meta.today || new Date().toISOString().slice(0, 10)).getTime();
278
+ var minT = Infinity;
279
+ var maxT = todayT;
280
+ events.forEach(function (ev) {
281
+ minT = Math.min(minT, parseDate(ev.start).getTime());
282
+ maxT = Math.max(maxT, parseDate(ev.end).getTime());
283
+ });
284
+ if (!isFinite(minT)) minT = maxT;
285
+
286
+ var minYear = new Date(minT).getFullYear();
287
+ var maxYear = new Date(maxT).getFullYear();
288
+ var rangeMs = maxT - minT || MS_DAY;
289
+ var chartW = 720;
290
+
291
+ function visibleChartWidth() {
292
+ return Math.max(480, (wrapper.clientWidth || 720) - LABEL_W);
293
+ }
294
+
295
+ function updateChartWidth() {
296
+ var visibleW = visibleChartWidth();
297
+ var maxZoomYears = Math.max(MIN_ZOOM_YEARS, rangeMs / MS_YEAR);
298
+ if (zoomYears > maxZoomYears) zoomYears = maxZoomYears;
299
+ chartW = Math.max(visibleW, visibleW * (rangeMs / (zoomYears * MS_YEAR)));
300
+ }
301
+
302
+ function formatZoomLabel() {
303
+ if (zoomYears >= 1) {
304
+ var rounded = Math.round(zoomYears * 10) / 10;
305
+ return (rounded % 1 === 0 ? rounded.toFixed(0) : rounded.toFixed(1)) + ' yr';
306
+ }
307
+ return Math.max(1, Math.round(zoomYears * 12)) + ' mo';
308
+ }
309
+
310
+ function updateZoomLabel() {
311
+ if (zoomLabel) zoomLabel.textContent = formatZoomLabel();
312
+ }
313
+
314
+ function changeZoom(direction) {
315
+ var maxZoomYears = Math.max(MIN_ZOOM_YEARS, rangeMs / MS_YEAR);
316
+ var factor = direction > 0 ? 0.75 : 1.33;
317
+ zoomYears = Math.max(MIN_ZOOM_YEARS, Math.min(maxZoomYears, zoomYears * factor));
318
+ updateZoomLabel();
319
+ render();
320
+ scrollToToday();
321
+ }
322
+
323
+ function scrollToToday() {
324
+ requestAnimationFrame(function () {
325
+ /* The right edge of the range is not today: a single far-future
326
+ entry (a dated draft, say) would otherwise open the chart on an
327
+ empty century. */
328
+ var todayX = xPosMs(todayT);
329
+ var maxScroll = Math.max(0, container.offsetWidth - wrapper.clientWidth);
330
+ wrapper.scrollLeft = Math.max(0, Math.min(maxScroll, todayX - wrapper.clientWidth + 24));
331
+ });
332
+ }
333
+
334
+ function xPos(dateStr) {
335
+ return LABEL_W + ((parseDate(dateStr).getTime() - minT) / rangeMs) * chartW;
336
+ }
337
+
338
+ function xPosMs(t) {
339
+ return LABEL_W + ((t - minT) / rangeMs) * chartW;
340
+ }
341
+
342
+ function width(startStr, endStr) {
343
+ return Math.max(2, xPos(endStr) - xPos(startStr));
344
+ }
345
+
346
+ function timeFromClientX(clientX) {
347
+ var rect = wrapper.getBoundingClientRect();
348
+ var x = clientX - rect.left + wrapper.scrollLeft - LABEL_W;
349
+ x = Math.max(0, Math.min(chartW, x));
350
+ return minT + (x / chartW) * rangeMs;
351
+ }
352
+
353
+ function overlapsRange(ev, startMs, endMs) {
354
+ var evStart = parseDate(ev.start).getTime();
355
+ var evEnd = parseDate(ev.end).getTime();
356
+ return evStart <= endMs && evEnd >= startMs;
357
+ }
358
+
359
+ function assignSublanes(items) {
360
+ var sorted = items.slice().sort(function (a, b) {
361
+ var diff = parseDate(a.start).getTime() - parseDate(b.start).getTime();
362
+ if (diff !== 0) return diff;
363
+ return parseDate(b.end).getTime() - parseDate(a.end).getTime();
364
+ });
365
+ var laneEnds = [];
366
+ sorted.forEach(function (item) {
367
+ var startT = parseDate(item.start).getTime();
368
+ var endT = parseDate(item.end).getTime();
369
+ var lane = 0;
370
+ for (; lane < laneEnds.length; lane++) {
371
+ if (startT >= laneEnds[lane]) break;
372
+ }
373
+ if (lane === laneEnds.length) laneEnds.push(endT);
374
+ else laneEnds[lane] = endT;
375
+ item._sublane = lane;
376
+ });
377
+ return Math.max(1, laneEnds.length);
378
+ }
379
+
380
+ function laneHeightFor(sublaneCount, base, laneH, gap) {
381
+ laneH = laneH == null ? SUBLANE_H : laneH;
382
+ gap = gap == null ? SUBLANE_GAP : gap;
383
+ return Math.max(base, LANE_PAD * 2 + sublaneCount * laneH + (sublaneCount - 1) * gap);
384
+ }
385
+
386
+ function sublaneTop(index, laneH, gap) {
387
+ laneH = laneH == null ? SUBLANE_H : laneH;
388
+ gap = gap == null ? SUBLANE_GAP : gap;
389
+ return LANE_PAD + index * (laneH + gap);
390
+ }
391
+
392
+ function showTip(html, x, y) {
393
+ tooltip.innerHTML = html;
394
+ tooltip.classList.remove('hidden');
395
+ var rect = wrapper.getBoundingClientRect();
396
+ var left = x - rect.left + wrapper.scrollLeft + 12;
397
+ var top = y - rect.top + wrapper.scrollTop + 12;
398
+ if (left + 280 > wrapper.scrollLeft + rect.width) left = x - rect.left + wrapper.scrollLeft - 290;
399
+ tooltip.style.left = left + 'px';
400
+ tooltip.style.top = top + 'px';
401
+ }
402
+
403
+ function hideTip() {
404
+ tooltip.classList.add('hidden');
405
+ }
406
+
407
+ function bindBar(bar, ev) {
408
+ bar.dataset.eventId = ev.id;
409
+ barRegistry.push({ bar: bar, ev: ev });
410
+ bar.addEventListener('mouseenter', function (e) {
411
+ var sub = ev.subtitle ? '<br><span class="text-tertiary">' + ev.subtitle + '</span>' : '';
412
+ showTip('<strong>' + ev.title + '</strong>' + sub + '<br>' + formatRange(ev), e.clientX, e.clientY);
413
+ });
414
+ bar.addEventListener('mousemove', function (e) {
415
+ showTip(tooltip.innerHTML, e.clientX, e.clientY);
416
+ });
417
+ bar.addEventListener('mouseleave', hideTip);
418
+ }
419
+
420
+ function writingGrain() {
421
+ // Coarser buckets when zoomed out so bars stay readable on a long career axis.
422
+ if (zoomYears >= 6) return 'year';
423
+ if (zoomYears >= 2.5) return 'quarter';
424
+ return 'month';
425
+ }
426
+
427
+ function bucketKeyForDate(dateStr, grain) {
428
+ var d = parseDate(dateStr);
429
+ var y = d.getFullYear();
430
+ var m = d.getMonth();
431
+ if (grain === 'year') return String(y);
432
+ if (grain === 'quarter') return y + '-Q' + (Math.floor(m / 3) + 1);
433
+ return y + '-' + String(m + 1).padStart(2, '0');
434
+ }
435
+
436
+ function bucketRange(key, grain) {
437
+ if (grain === 'year') {
438
+ return { start: key + '-01-01', end: key + '-12-31', label: key };
439
+ }
440
+ if (grain === 'quarter') {
441
+ var parts = key.split('-Q');
442
+ var y = parts[0];
443
+ var q = parseInt(parts[1], 10);
444
+ var startMonth = (q - 1) * 3 + 1;
445
+ var endMonth = startMonth + 2;
446
+ var endDay = endMonth === 2 ? 28 : ([4, 6, 9, 11].indexOf(endMonth) >= 0 ? 30 : 31);
447
+ return {
448
+ start: y + '-' + String(startMonth).padStart(2, '0') + '-01',
449
+ end: y + '-' + String(endMonth).padStart(2, '0') + '-' + String(endDay).padStart(2, '0'),
450
+ label: key
451
+ };
452
+ }
453
+ var ym = key.split('-');
454
+ var year = parseInt(ym[0], 10);
455
+ var month = parseInt(ym[1], 10);
456
+ var last = new Date(year, month, 0).getDate();
457
+ return {
458
+ start: key + '-01',
459
+ end: key + '-' + String(last).padStart(2, '0'),
460
+ label: key
461
+ };
462
+ }
463
+
464
+ function aggregateWritingBuckets(itemKey) {
465
+ var grain = writingGrain();
466
+ var map = {};
467
+ (buckets || []).forEach(function (monthBucket) {
468
+ var items = monthBucket[itemKey] || [];
469
+ items.forEach(function (item) {
470
+ var key = bucketKeyForDate(item.date || (monthBucket.month + '-01'), grain);
471
+ if (!map[key]) {
472
+ map[key] = { key: key, count: 0, items: [] };
473
+ }
474
+ map[key].count += 1;
475
+ map[key].items.push(item);
476
+ });
477
+ });
478
+ return Object.keys(map).sort().map(function (key) {
479
+ var entry = map[key];
480
+ var range = bucketRange(key, grain);
481
+ entry.start = range.start;
482
+ entry.end = range.end;
483
+ entry.label = range.label;
484
+ entry.grain = grain;
485
+ return entry;
486
+ });
487
+ }
488
+
489
+ function renderWritingDensity(track, itemKey, toneClass) {
490
+ var densBuckets = aggregateWritingBuckets(itemKey);
491
+ var maxCount = 1;
492
+ densBuckets.forEach(function (b) { maxCount = Math.max(maxCount, b.count); });
493
+ densBuckets.forEach(function (b) {
494
+ var bar = document.createElement('div');
495
+ bar.className = 'timeline-density-bar' + toneClass;
496
+ var h = 8 + (b.count / maxCount) * 28;
497
+ var left = xPos(b.start) - LABEL_W;
498
+ var rawW = width(b.start, b.end);
499
+ var gap = Math.min(3, Math.max(1, rawW * 0.08));
500
+ bar.style.left = (left + gap / 2) + 'px';
501
+ bar.style.width = Math.max(2, rawW - gap) + 'px';
502
+ bar.style.height = h + 'px';
503
+ bar.addEventListener('mouseenter', function (e) {
504
+ var list = (b.items || []).map(function (item) {
505
+ return '<span class="block truncate">' + item.title + '</span>';
506
+ }).join('');
507
+ showTip('<strong>' + b.label + '</strong> (' + b.count + ')<br>' + list, e.clientX, e.clientY);
508
+ });
509
+ bar.addEventListener('mouseleave', hideTip);
510
+ track.appendChild(bar);
511
+ });
512
+ }
513
+
514
+ function renderWritingMarkers(track, type) {
515
+ var writingItems = events.filter(function (e) { return e.type === type; });
516
+ var writingSublanes = assignSublanes(writingItems);
517
+ writingItems.forEach(function (ev) {
518
+ var m = document.createElement('div');
519
+ m.className = 'timeline-marker';
520
+ m.style.left = (xPos(ev.start) - LABEL_W) + 'px';
521
+ m.style.top = (sublaneTop(ev._sublane) + SUBLANE_H / 2) + 'px';
522
+ m.style.background = COLORS[ev.type];
523
+ bindBar(m, ev);
524
+ track.appendChild(m);
525
+ });
526
+ track.dataset.sublanes = writingSublanes;
527
+ return writingSublanes;
528
+ }
529
+
530
+ function renderSelectionList(listEl, items) {
531
+ listEl.innerHTML = '';
532
+ items.forEach(function (ev) {
533
+ var li = document.createElement('li');
534
+ var sub = ev.subtitle ? ' <span class="text-on-surface-variant">· ' + ev.subtitle + '</span>' : '';
535
+ if (ev.url) {
536
+ li.innerHTML = '<a href="' + ev.url + '" class="text-primary hover:underline">' + ev.title + '</a>' + sub +
537
+ '<br><span class="font-label text-xs text-on-surface-variant tabular-nums">' + formatRange(ev) + '</span>';
538
+ } else {
539
+ li.innerHTML = '<span class="text-on-surface">' + ev.title + '</span>' + sub +
540
+ '<br><span class="font-label text-xs text-on-surface-variant tabular-nums">' + formatRange(ev) + '</span>';
541
+ }
542
+ listEl.appendChild(li);
543
+ });
544
+ }
545
+
546
+ function applySelectionHighlight() {
547
+ barRegistry.forEach(function (entry) {
548
+ var bar = entry.bar;
549
+ var ev = entry.ev;
550
+ bar.classList.remove('is-dimmed', 'is-selected-range', 'is-highlighted');
551
+ if (!selection) return;
552
+ if (overlapsRange(ev, selection.startMs, selection.endMs)) {
553
+ bar.classList.add('is-selected-range');
554
+ } else if (ev.type === 'project' || ev.type === 'education') {
555
+ bar.classList.add('is-dimmed');
556
+ }
557
+ });
558
+ }
559
+
560
+ function updateSelectionPanel() {
561
+ if (!selection) {
562
+ selectionPanel.hidden = true;
563
+ return;
564
+ }
565
+ selectionPanel.hidden = false;
566
+ selectionRange.textContent = formatMonth(new Date(selection.startMs)) + ' – ' + formatMonth(new Date(selection.endMs));
567
+
568
+ var edu = events.filter(function (e) {
569
+ return e.type === 'education' && overlapsRange(e, selection.startMs, selection.endMs);
570
+ });
571
+ var projects = events.filter(function (e) {
572
+ return e.type === 'project' && overlapsRange(e, selection.startMs, selection.endMs);
573
+ });
574
+
575
+ selectionEducationWrap.hidden = edu.length === 0;
576
+ selectionProjectsWrap.hidden = projects.length === 0;
577
+ selectionEmpty.hidden = edu.length > 0 || projects.length > 0;
578
+
579
+ renderSelectionList(selectionEducation, edu);
580
+ renderSelectionList(selectionProjects, projects);
581
+ applySelectionHighlight();
582
+ }
583
+
584
+ function clearSelection() {
585
+ selection = null;
586
+ if (selectionBand) selectionBand.hidden = true;
587
+ updateSelectionPanel();
588
+ applySelectionHighlight();
589
+ }
590
+
591
+ function setSelection(startMs, endMs) {
592
+ if (Math.abs(endMs - startMs) < MS_DAY * 20) {
593
+ clearSelection();
594
+ return;
595
+ }
596
+ selection = {
597
+ startMs: Math.min(startMs, endMs),
598
+ endMs: Math.max(startMs, endMs)
599
+ };
600
+ if (selectionBand) {
601
+ selectionBand.hidden = false;
602
+ selectionBand.style.left = (xPosMs(selection.startMs) - LABEL_W) + 'px';
603
+ selectionBand.style.width = Math.max(2, xPosMs(selection.endMs) - xPosMs(selection.startMs)) + 'px';
604
+ }
605
+ updateSelectionPanel();
606
+ }
607
+
608
+ function setupSelectionDrag(track) {
609
+ track.addEventListener('mousedown', function (e) {
610
+ if (e.button !== 0 || e.target !== track) return;
611
+ dragState.active = true;
612
+ dragState.anchorMs = timeFromClientX(e.clientX);
613
+ setSelection(dragState.anchorMs, dragState.anchorMs);
614
+ e.preventDefault();
615
+ });
616
+ }
617
+
618
+ window.addEventListener('mousemove', function (e) {
619
+ if (!dragState.active) return;
620
+ setSelection(dragState.anchorMs, timeFromClientX(e.clientX));
621
+ });
622
+
623
+ window.addEventListener('mouseup', function () {
624
+ dragState.active = false;
625
+ });
626
+
627
+ function renderBars(track, items, color, barHeight, ongoingAllowed, laneH, gap) {
628
+ items.forEach(function (ev) {
629
+ var bar = document.createElement('div');
630
+ bar.className = 'timeline-bar' + (ongoingAllowed && ev.ongoing ? ' timeline-bar--ongoing' : '');
631
+ bar.style.left = (xPos(ev.start) - LABEL_W) + 'px';
632
+ bar.style.width = width(ev.start, ev.end) + 'px';
633
+ bar.style.top = sublaneTop(ev._sublane || 0, laneH, gap) + 'px';
634
+ bar.style.height = barHeight + 'px';
635
+ bar.style.background = color;
636
+ bindBar(bar, ev);
637
+ track.appendChild(bar);
638
+ });
639
+ }
640
+
641
+ function render() {
642
+ updateChartWidth();
643
+ container.innerHTML = '';
644
+ barRegistry = [];
645
+ container.style.width = (LABEL_W + chartW + 32) + 'px';
646
+
647
+ var y = 0;
648
+ var axis = document.createElement('div');
649
+ axis.className = 'timeline-axis flex';
650
+ axis.style.height = AXIS_H + 'px';
651
+ axis.style.width = (LABEL_W + chartW) + 'px';
652
+
653
+ var axisGutter = document.createElement('div');
654
+ axisGutter.className = 'timeline-axis-gutter shrink-0 h-full';
655
+ axisGutter.style.width = LABEL_W + 'px';
656
+ axis.appendChild(axisGutter);
657
+
658
+ var axisTrack = document.createElement('div');
659
+ axisTrack.className = 'relative shrink-0 h-full';
660
+ axisTrack.style.width = chartW + 'px';
661
+
662
+ for (var year = minYear; year <= maxYear + 1; year++) {
663
+ var d = year + '-01-01';
664
+ var x = xPos(d) - LABEL_W;
665
+ var tick = document.createElement('div');
666
+ tick.className = 'absolute font-label text-xs text-on-surface-variant';
667
+ tick.style.left = x + 'px';
668
+ tick.style.top = '10px';
669
+ tick.textContent = year;
670
+ axisTrack.appendChild(tick);
671
+ }
672
+ axis.appendChild(axisTrack);
673
+ container.appendChild(axis);
674
+ y += AXIS_H;
675
+
676
+ var lanesTop = y;
677
+
678
+ function addLane(key, label, renderFn, height) {
679
+ if (!laneVisible[key]) return;
680
+ var row = document.createElement('div');
681
+ row.className = 'timeline-lane-row flex';
682
+ row.style.height = height + 'px';
683
+ row.dataset.lane = key;
684
+
685
+ var lbl = document.createElement('div');
686
+ lbl.className = 'timeline-lane-label shrink-0 h-full flex items-center font-label text-xs text-on-surface-variant px-2';
687
+ lbl.style.width = LABEL_W + 'px';
688
+ lbl.textContent = label;
689
+ row.appendChild(lbl);
690
+
691
+ var track = document.createElement('div');
692
+ track.className = 'timeline-track-select relative shrink-0 h-full';
693
+ track.style.width = chartW + 'px';
694
+ renderFn(track);
695
+ row.appendChild(track);
696
+
697
+ container.appendChild(row);
698
+ y += height;
699
+ }
700
+
701
+ var jobs = events.filter(function (e) { return e.type === 'employment'; });
702
+ var products = events.filter(function (e) { return e.type === 'product'; });
703
+ var jobSublanes = assignSublanes(jobs);
704
+ var productSublanes = assignSublanes(products);
705
+ var jobBlockH = laneHeightFor(jobSublanes, BASE_LANE_H.career);
706
+ var productBlockH = products.length ? laneHeightFor(productSublanes, LANE_PAD + SUBLANE_H_THIN, SUBLANE_H_THIN, SUBLANE_GAP_THIN) : 0;
707
+ var careerH = jobBlockH + (products.length ? productBlockH + SUBLANE_GAP_THIN : 0);
708
+
709
+ addLane('career', 'Career', function (track) {
710
+ renderBars(track, jobs, COLORS.employment, 18, true);
711
+ products.forEach(function (ev) {
712
+ var bar = document.createElement('div');
713
+ bar.className = 'timeline-bar' + (ev.ongoing ? ' timeline-bar--ongoing' : '');
714
+ bar.style.left = (xPos(ev.start) - LABEL_W) + 'px';
715
+ bar.style.width = width(ev.start, ev.end) + 'px';
716
+ bar.style.top = (jobBlockH + SUBLANE_GAP_THIN + (ev._sublane || 0) * (SUBLANE_H_THIN + SUBLANE_GAP_THIN)) + 'px';
717
+ bar.style.height = '10px';
718
+ bar.style.background = COLORS.product;
719
+ bindBar(bar, ev);
720
+ track.appendChild(bar);
721
+ });
722
+ setupSelectionDrag(track);
723
+ }, careerH);
724
+
725
+ var eduItems = events.filter(function (e) { return e.type === 'education'; });
726
+ var eduSublanes = assignSublanes(eduItems);
727
+ addLane('education', 'Education', function (track) {
728
+ renderBars(track, eduItems, COLORS.education, 16, false);
729
+ setupSelectionDrag(track);
730
+ }, laneHeightFor(eduSublanes, BASE_LANE_H.education));
731
+
732
+ var projectItems = events.filter(function (e) { return e.type === 'project'; });
733
+ var projectSublanes = assignSublanes(projectItems);
734
+ addLane('personal', 'Personal', function (track) {
735
+ renderBars(track, projectItems, COLORS.project, 10, true, SUBLANE_H_THIN, SUBLANE_GAP_THIN);
736
+ setupSelectionDrag(track);
737
+ }, laneHeightFor(projectSublanes, BASE_LANE_H.personal, SUBLANE_H_THIN, SUBLANE_GAP_THIN));
738
+
739
+ var articleItems = events.filter(function (e) { return e.type === 'article'; });
740
+ var articleSublanes = writingExpanded ? assignSublanes(articleItems) : 1;
741
+ var articlesH = writingExpanded
742
+ ? laneHeightFor(articleSublanes, BASE_LANE_H.articles)
743
+ : BASE_LANE_H.articles;
744
+ addLane('articles', 'Articles', function (track) {
745
+ if (writingExpanded) renderWritingMarkers(track, 'article');
746
+ else renderWritingDensity(track, 'articles', 'timeline-density-bar--article');
747
+ }, articlesH);
748
+
749
+ var postItems = events.filter(function (e) { return e.type === 'post'; });
750
+ var postSublanes = writingExpanded ? assignSublanes(postItems) : 1;
751
+ var postsH = writingExpanded
752
+ ? laneHeightFor(postSublanes, BASE_LANE_H.posts)
753
+ : BASE_LANE_H.posts;
754
+ addLane('posts', 'Posts', function (track) {
755
+ if (writingExpanded) renderWritingMarkers(track, 'post');
756
+ else renderWritingDensity(track, 'posts', 'timeline-density-bar--post');
757
+ }, postsH);
758
+
759
+ var laneAreaH = y - lanesTop;
760
+ var gridLayer = document.createElement('div');
761
+ gridLayer.className = 'timeline-grid-layer absolute pointer-events-none';
762
+ gridLayer.style.left = LABEL_W + 'px';
763
+ gridLayer.style.top = lanesTop + 'px';
764
+ gridLayer.style.width = chartW + 'px';
765
+ gridLayer.style.height = laneAreaH + 'px';
766
+ gridLayer.style.zIndex = '0';
767
+ gridLayer.style.overflow = 'hidden';
768
+ for (var gridYear = minYear; gridYear <= maxYear + 1; gridYear++) {
769
+ var gridLine = document.createElement('div');
770
+ gridLine.className = 'absolute top-0';
771
+ gridLine.style.left = (xPos(gridYear + '-01-01') - LABEL_W) + 'px';
772
+ gridLine.style.width = '1px';
773
+ gridLine.style.height = laneAreaH + 'px';
774
+ gridLine.style.background = 'color-mix(in srgb, var(--color-outline-variant) 35%, transparent)';
775
+ gridLayer.appendChild(gridLine);
776
+ }
777
+ container.appendChild(gridLayer);
778
+
779
+ selectionLayer = document.createElement('div');
780
+ selectionLayer.className = 'absolute';
781
+ selectionLayer.style.left = LABEL_W + 'px';
782
+ selectionLayer.style.top = lanesTop + 'px';
783
+ selectionLayer.style.width = chartW + 'px';
784
+ selectionLayer.style.height = (y - lanesTop) + 'px';
785
+ selectionLayer.style.pointerEvents = 'none';
786
+ selectionLayer.style.zIndex = '1';
787
+
788
+ selectionBand = document.createElement('div');
789
+ selectionBand.className = 'timeline-selection-band';
790
+ selectionBand.hidden = !selection;
791
+ if (selection) {
792
+ selectionBand.style.left = (xPosMs(selection.startMs) - LABEL_W) + 'px';
793
+ selectionBand.style.width = Math.max(2, xPosMs(selection.endMs) - xPosMs(selection.startMs)) + 'px';
794
+ }
795
+ selectionLayer.appendChild(selectionBand);
796
+ container.appendChild(selectionLayer);
797
+
798
+ container.style.height = y + 'px';
799
+ container.style.minHeight = '';
800
+ applySelectionHighlight();
801
+ }
802
+
803
+ var resizeTimer;
804
+ window.addEventListener('resize', function () {
805
+ clearTimeout(resizeTimer);
806
+ resizeTimer = setTimeout(function () {
807
+ render();
808
+ scrollToToday();
809
+ }, 150);
810
+ });
811
+
812
+ /* The bars carry their colour as an inline style, so a palette swap has to
813
+ redraw them — the CSS variables alone cannot reach an already-painted
814
+ bar. `shell-theme-change` is dispatched by theme/shell-theme-boot.html. */
815
+ document.addEventListener('shell-theme-change', function () {
816
+ refreshColors();
817
+ render();
818
+ });
819
+
820
+ document.querySelectorAll('.timeline-lane-toggle').forEach(function (cb) {
821
+ cb.addEventListener('change', function () {
822
+ laneVisible[cb.dataset.lane] = cb.checked;
823
+ render();
824
+ });
825
+ });
826
+
827
+ var expandCb = document.getElementById('timeline-writing-expanded');
828
+ if (expandCb) {
829
+ expandCb.addEventListener('change', function () {
830
+ writingExpanded = expandCb.checked;
831
+ render();
832
+ });
833
+ }
834
+
835
+ if (selectionClear) {
836
+ selectionClear.addEventListener('click', clearSelection);
837
+ }
838
+
839
+ if (zoomInBtn) {
840
+ zoomInBtn.addEventListener('click', function () { changeZoom(1); });
841
+ }
842
+ if (zoomOutBtn) {
843
+ zoomOutBtn.addEventListener('click', function () { changeZoom(-1); });
844
+ }
845
+
846
+ updateZoomLabel();
847
+ render();
848
+ scrollToToday();
849
+ }
850
+
851
+ function loadTimeline() {
852
+ var runtime = window.ContentRuntime;
853
+ /* The content base is empty when the JSON is served from the site root
854
+ (local preview), which is a valid source, not a missing one — gate on
855
+ the loader instead, and let the catch fall back to the inline stub. */
856
+ if (runtime && typeof runtime.loadJson === 'function') {
857
+ runtime.loadJson('data/timeline.json').then(bootTimeline).catch(function (error) {
858
+ console.error(error);
859
+ if (el) bootTimeline(JSON.parse(el.textContent));
860
+ });
861
+ return;
862
+ }
863
+ if (el) bootTimeline(JSON.parse(el.textContent));
864
+ }
865
+
866
+ loadTimeline();
867
+ })();
868
+ </script>