@hraness/design-kit 0.31.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 (345) hide show
  1. package/ARTICLE_COPY.md +208 -0
  2. package/HERO_FIELDS.md +55 -0
  3. package/ICONS.md +56 -0
  4. package/LANTERN_MATERIAL.md +60 -0
  5. package/LICENSE +21 -0
  6. package/MARKETING_COPY.md +49 -0
  7. package/MARKETING_PRESET.md +108 -0
  8. package/PALETTES.md +90 -0
  9. package/PAPER_THEME.md +128 -0
  10. package/README.md +745 -0
  11. package/STATUS_PAGES.md +67 -0
  12. package/STYLE.md +245 -0
  13. package/WRITING.md +72 -0
  14. package/dist/browser/index.js +2345 -0
  15. package/dist/chunk-44djxk16.js +6872 -0
  16. package/dist/chunk-5gtx3pza.js +9 -0
  17. package/dist/chunk-77391vmq.js +454 -0
  18. package/dist/chunk-7b1586eb.js +1209 -0
  19. package/dist/chunk-8834fh4n.js +6 -0
  20. package/dist/chunk-9t8xyqte.js +870 -0
  21. package/dist/chunk-cejpzyfh.js +446 -0
  22. package/dist/chunk-eh71jz57.js +429 -0
  23. package/dist/chunk-evb02bc1.js +516 -0
  24. package/dist/chunk-h4k7yv6x.js +368 -0
  25. package/dist/chunk-he8eznb1.js +760 -0
  26. package/dist/chunk-mr1vdcjq.js +404 -0
  27. package/dist/chunk-wzvdn8ey.js +116 -0
  28. package/dist/fonts/nebula-sans/social-fonts.generated.js +53 -0
  29. package/dist/icons.js +48 -0
  30. package/dist/index.js +237 -0
  31. package/dist/launch.js +41 -0
  32. package/dist/mockups/client.js +422 -0
  33. package/dist/mockups/index.js +1850 -0
  34. package/dist/portfolio.js +1549 -0
  35. package/dist/provider-marks.js +19 -0
  36. package/dist/react/charts.js +11 -0
  37. package/dist/react/hero-backdrop.js +8 -0
  38. package/dist/react/index.js +7252 -0
  39. package/dist/react/platform-install.js +10 -0
  40. package/dist/react/server.js +137 -0
  41. package/dist/stylex-manifest.json +1 -0
  42. package/dist/stylex.css +5392 -0
  43. package/dist/syntax-highlighting.js +15 -0
  44. package/dist/testing.js +250 -0
  45. package/package.json +377 -0
  46. package/portfolio-inventory.json +31 -0
  47. package/scripts/check-lantern-material-snapshot.d.mts +26 -0
  48. package/scripts/check-lantern-material-snapshot.mjs +126 -0
  49. package/scripts/check-marketing-snapshot.d.mts +12 -0
  50. package/scripts/check-marketing-snapshot.mjs +58 -0
  51. package/scripts/lantern-material-snapshot.ts +101 -0
  52. package/scripts/marketing-textures.ts +48 -0
  53. package/scripts/paper-theme-snapshot.ts +97 -0
  54. package/scripts/product-marketing-snapshot.ts +149 -0
  55. package/src/appearance-menu.css +266 -0
  56. package/src/appearance.ts +32 -0
  57. package/src/article-html.ts +269 -0
  58. package/src/article.ts +686 -0
  59. package/src/browser/appearance-menu.ts +604 -0
  60. package/src/browser/artifact-share.ts +164 -0
  61. package/src/browser/design-palette.ts +197 -0
  62. package/src/browser/foil.ts +124 -0
  63. package/src/browser/hero-light.ts +11 -0
  64. package/src/browser/index.ts +29 -0
  65. package/src/browser/status-field.ts +432 -0
  66. package/src/browser/status-page.ts +100 -0
  67. package/src/browser/sticky-offset.ts +82 -0
  68. package/src/browser/theme-color-sync.ts +213 -0
  69. package/src/charts.css +7 -0
  70. package/src/compiler-components.css +34 -0
  71. package/src/compiler-foundation.css +18 -0
  72. package/src/compiler-palettes.css +7 -0
  73. package/src/compiler-tokens.css +342 -0
  74. package/src/components.css +3 -0
  75. package/src/design-gallery.css +431 -0
  76. package/src/effects.css +14 -0
  77. package/src/fonts/geist-mono/GeistMono[wght].woff2 +0 -0
  78. package/src/fonts/geist-mono/OFL.txt +93 -0
  79. package/src/fonts/geist-mono/PROVENANCE.md +12 -0
  80. package/src/fonts/instrument-serif/OFL.txt +93 -0
  81. package/src/fonts/instrument-serif/UPSTREAM.md +14 -0
  82. package/src/fonts/instrument-serif/instrument-serif-latin-400.woff2 +0 -0
  83. package/src/fonts/nebula-sans/LICENSE.txt +96 -0
  84. package/src/fonts/nebula-sans/NebulaSans-Black.woff2 +0 -0
  85. package/src/fonts/nebula-sans/NebulaSans-BlackItalic.woff2 +0 -0
  86. package/src/fonts/nebula-sans/NebulaSans-Bold.otf +0 -0
  87. package/src/fonts/nebula-sans/NebulaSans-Bold.woff2 +0 -0
  88. package/src/fonts/nebula-sans/NebulaSans-BoldItalic.woff2 +0 -0
  89. package/src/fonts/nebula-sans/NebulaSans-Book.otf +0 -0
  90. package/src/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
  91. package/src/fonts/nebula-sans/NebulaSans-BookItalic.woff2 +0 -0
  92. package/src/fonts/nebula-sans/NebulaSans-Light.woff2 +0 -0
  93. package/src/fonts/nebula-sans/NebulaSans-LightItalic.woff2 +0 -0
  94. package/src/fonts/nebula-sans/NebulaSans-Medium.woff2 +0 -0
  95. package/src/fonts/nebula-sans/NebulaSans-MediumItalic.woff2 +0 -0
  96. package/src/fonts/nebula-sans/NebulaSans-Semibold.woff2 +0 -0
  97. package/src/fonts/nebula-sans/NebulaSans-SemiboldItalic.woff2 +0 -0
  98. package/src/fonts/nebula-sans/PROVENANCE.md +14 -0
  99. package/src/fonts/nebula-sans/social-fonts.generated.ts +3873 -0
  100. package/src/fonts.css +106 -0
  101. package/src/icons/act60/act60.svg +5 -0
  102. package/src/icons/act60/application.svg +9 -0
  103. package/src/icons/act60/chapter-compare.svg +3 -0
  104. package/src/icons/act60/decree-residence.svg +6 -0
  105. package/src/icons/act60/estimate.svg +7 -0
  106. package/src/icons/act60/export-services.svg +4 -0
  107. package/src/icons/act60/investor.svg +13 -0
  108. package/src/icons/act60/presence.svg +7 -0
  109. package/src/icons/aicharts/aicharts.svg +6 -0
  110. package/src/icons/aicharts/cost-compare.svg +4 -0
  111. package/src/icons/aicharts/task-audio.svg +7 -0
  112. package/src/icons/aicharts/task-coding.svg +6 -0
  113. package/src/icons/aicharts/task-images.svg +3 -0
  114. package/src/icons/aicharts/task-reasoning.svg +9 -0
  115. package/src/icons/aicharts/task-research.svg +5 -0
  116. package/src/icons/aicharts/task-video.svg +4 -0
  117. package/src/icons/manifest.json +1371 -0
  118. package/src/icons/platonik/conversation.svg +3 -0
  119. package/src/icons/platonik/creature.svg +7 -0
  120. package/src/icons/platonik/economy.svg +4 -0
  121. package/src/icons/platonik/keep-light.svg +6 -0
  122. package/src/icons/platonik/platonik.svg +7 -0
  123. package/src/icons/platonik/spark-route.svg +15 -0
  124. package/src/icons/receipts/act60-retry.receipt.json +888 -0
  125. package/src/icons/receipts/act60.receipt.json +1366 -0
  126. package/src/icons/receipts/aicharts.receipt.json +1546 -0
  127. package/src/icons/receipts/platonik.receipt.json +1011 -0
  128. package/src/icons/receipts/roughday.receipt.json +770 -0
  129. package/src/icons/receipts/shared.receipt.json +1296 -0
  130. package/src/icons/receipts/slopcamera.receipt.json +1495 -0
  131. package/src/icons/receipts/soundfish-retry.receipt.json +460 -0
  132. package/src/icons/receipts/soundfish.receipt.json +1709 -0
  133. package/src/icons/receipts/sponge.receipt.json +1743 -0
  134. package/src/icons/receipts/stripe-history-retry.receipt.json +145 -0
  135. package/src/icons/receipts/stripe-history.receipt.json +1202 -0
  136. package/src/icons/receipts/wordcell.receipt.json +1924 -0
  137. package/src/icons/roughday/finance.svg +7 -0
  138. package/src/icons/roughday/roughday.svg +6 -0
  139. package/src/icons/roughday/technology.svg +4 -0
  140. package/src/icons/roughday/world.svg +4 -0
  141. package/src/icons/sets/act60.json +63 -0
  142. package/src/icons/sets/aicharts.json +63 -0
  143. package/src/icons/sets/platonik.json +39 -0
  144. package/src/icons/sets/roughday.json +25 -0
  145. package/src/icons/sets/shared.json +12 -0
  146. package/src/icons/sets/slopcamera.json +47 -0
  147. package/src/icons/sets/soundfish.json +43 -0
  148. package/src/icons/sets/sponge.json +51 -0
  149. package/src/icons/sets/stripe-history.json +39 -0
  150. package/src/icons/sets/wordcell.json +51 -0
  151. package/src/icons/shared/agent-skill.svg +4 -0
  152. package/src/icons/shared/cli.svg +5 -0
  153. package/src/icons/shared/privacy.svg +3 -0
  154. package/src/icons/shared/research.svg +7 -0
  155. package/src/icons/shared/sdk.svg +3 -0
  156. package/src/icons/slopcamera/diagram.svg +3 -0
  157. package/src/icons/slopcamera/direct-scene.svg +5 -0
  158. package/src/icons/slopcamera/edit-video.svg +17 -0
  159. package/src/icons/slopcamera/mcp.svg +3 -0
  160. package/src/icons/slopcamera/native-world.svg +3 -0
  161. package/src/icons/slopcamera/slopcamera.svg +11 -0
  162. package/src/icons/soundfish/agent-ui.svg +7 -0
  163. package/src/icons/soundfish/collaborate.svg +5 -0
  164. package/src/icons/soundfish/compose.svg +3 -0
  165. package/src/icons/soundfish/hear.svg +6 -0
  166. package/src/icons/soundfish/human-ui.svg +6 -0
  167. package/src/icons/soundfish/share.svg +3 -0
  168. package/src/icons/soundfish/soundfish.svg +6 -0
  169. package/src/icons/sponge/api.svg +3 -0
  170. package/src/icons/sponge/documents.svg +3 -0
  171. package/src/icons/sponge/library.svg +3 -0
  172. package/src/icons/sponge/one-question.svg +5 -0
  173. package/src/icons/sponge/people-direct.svg +5 -0
  174. package/src/icons/sponge/read.svg +3 -0
  175. package/src/icons/sponge/save.svg +4 -0
  176. package/src/icons/sponge/sponge.svg +7 -0
  177. package/src/icons/stripe-history/company-history.svg +3 -0
  178. package/src/icons/stripe-history/evidence.svg +4 -0
  179. package/src/icons/stripe-history/independence.svg +7 -0
  180. package/src/icons/stripe-history/publications.svg +11 -0
  181. package/src/icons/stripe-history/sources.svg +6 -0
  182. package/src/icons/stripe-history/stripe-history.svg +3 -0
  183. package/src/icons/wordcell/backlinks.svg +6 -0
  184. package/src/icons/wordcell/capture.svg +6 -0
  185. package/src/icons/wordcell/git-provenance.svg +4 -0
  186. package/src/icons/wordcell/kb.svg +4 -0
  187. package/src/icons/wordcell/markdown.svg +4 -0
  188. package/src/icons/wordcell/scopes.svg +3 -0
  189. package/src/icons/wordcell/search.svg +7 -0
  190. package/src/icons.generated.ts +734 -0
  191. package/src/icons.ts +51 -0
  192. package/src/index.ts +275 -0
  193. package/src/lantern-material.css +213 -0
  194. package/src/launch.ts +624 -0
  195. package/src/marketing-assets/UPSTREAM.md +18 -0
  196. package/src/marketing-assets/cells.svg +1 -0
  197. package/src/marketing-assets/grain.svg +1 -0
  198. package/src/marketing-forced-colors.css +12 -0
  199. package/src/mockups/client.tsx +415 -0
  200. package/src/mockups/core.tsx +318 -0
  201. package/src/mockups/frames.tsx +368 -0
  202. package/src/mockups/index.ts +14 -0
  203. package/src/mockups/surfaces.tsx +681 -0
  204. package/src/mockups.css +2607 -0
  205. package/src/palette-appearance.ts +45 -0
  206. package/src/palette-bridge.css +124 -0
  207. package/src/palette-color.ts +41 -0
  208. package/src/palette-system.css +721 -0
  209. package/src/palette-themes.ts +23 -0
  210. package/src/palette-tokens.stylex.ts +519 -0
  211. package/src/palettes.css +3 -0
  212. package/src/palettes.ts +161 -0
  213. package/src/paper-theme.css +244 -0
  214. package/src/plain-publication.css +1241 -0
  215. package/src/plain-site.css +397 -0
  216. package/src/platforms.ts +151 -0
  217. package/src/portfolio.generated.json +1595 -0
  218. package/src/portfolio.generated.ts +1596 -0
  219. package/src/portfolio.ts +226 -0
  220. package/src/product-marketing-foundation.css +433 -0
  221. package/src/product-marketing-preset.css +261 -0
  222. package/src/product-marketing.css +2186 -0
  223. package/src/provider-marks.generated.ts +112 -0
  224. package/src/provider-marks.ts +143 -0
  225. package/src/react/animated-rail-stage.stylex.ts +15 -0
  226. package/src/react/animated-rail-stage.tsx +51 -0
  227. package/src/react/app-shell.stylex.ts +104 -0
  228. package/src/react/app-shell.tsx +121 -0
  229. package/src/react/article.tsx +602 -0
  230. package/src/react/aurora-dots-background.tsx +34 -0
  231. package/src/react/charts.stylex.ts +563 -0
  232. package/src/react/charts.tsx +610 -0
  233. package/src/react/chat.stylex.ts +34 -0
  234. package/src/react/chat.tsx +155 -0
  235. package/src/react/design-gallery.tsx +943 -0
  236. package/src/react/design-palette.stylex.ts +124 -0
  237. package/src/react/design-palette.tsx +196 -0
  238. package/src/react/design-theme-context.tsx +41 -0
  239. package/src/react/effects.stylex.ts +325 -0
  240. package/src/react/fader.stylex.ts +71 -0
  241. package/src/react/fader.tsx +151 -0
  242. package/src/react/foil-card-math.ts +100 -0
  243. package/src/react/foil-card-surface.stylex.ts +326 -0
  244. package/src/react/foil-card-surface.tsx +742 -0
  245. package/src/react/foil-mark.tsx +35 -0
  246. package/src/react/foil.stylex.ts +163 -0
  247. package/src/react/haptics.ts +228 -0
  248. package/src/react/hero-backdrop.tsx +22 -0
  249. package/src/react/index.ts +38 -0
  250. package/src/react/keyboard-shortcuts.ts +228 -0
  251. package/src/react/lantern-material-gallery.tsx +166 -0
  252. package/src/react/lantern-material.stylex.ts +39 -0
  253. package/src/react/launch-beats.tsx +73 -0
  254. package/src/react/navigation-rail.stylex.ts +130 -0
  255. package/src/react/navigation-rail.tsx +228 -0
  256. package/src/react/particle-halo.tsx +115 -0
  257. package/src/react/phaser-dots.tsx +501 -0
  258. package/src/react/platform-icons.tsx +94 -0
  259. package/src/react/platform-install.stylex.ts +385 -0
  260. package/src/react/platform-install.tsx +389 -0
  261. package/src/react/playback-transport.stylex.ts +14 -0
  262. package/src/react/playback-transport.tsx +112 -0
  263. package/src/react/procedural-backdrop.tsx +192 -0
  264. package/src/react/procedural-recipe.ts +323 -0
  265. package/src/react/product-marketing.stylex.ts +4469 -0
  266. package/src/react/product-marketing.tsx +1611 -0
  267. package/src/react/production-data-preview-notice.stylex.ts +40 -0
  268. package/src/react/production-data-preview-notice.tsx +34 -0
  269. package/src/react/provider-mark.stylex.ts +132 -0
  270. package/src/react/provider-mark.tsx +120 -0
  271. package/src/react/relative-time.tsx +117 -0
  272. package/src/react/route-state.stylex.ts +39 -0
  273. package/src/react/route-state.tsx +350 -0
  274. package/src/react/server.ts +11 -0
  275. package/src/react/social-kit-panel.tsx +103 -0
  276. package/src/react/sticky-offset.tsx +33 -0
  277. package/src/react/surfaces.stylex.ts +179 -0
  278. package/src/react/surfaces.tsx +330 -0
  279. package/src/react/syntax-code.tsx +33 -0
  280. package/src/react/theme-color-sync.ts +1 -0
  281. package/src/react/theme-resolution.ts +11 -0
  282. package/src/react/theme.stylex.ts +276 -0
  283. package/src/react/theme.tsx +402 -0
  284. package/src/reading.css +72 -0
  285. package/src/relative-time.ts +185 -0
  286. package/src/reset.css +2 -0
  287. package/src/site-shell.css +16 -0
  288. package/src/status-page-html.ts +63 -0
  289. package/src/status-page.css +276 -0
  290. package/src/status-page.ts +323 -0
  291. package/src/styles.css +21 -0
  292. package/src/syntax-code-lexers.ts +303 -0
  293. package/src/syntax-highlighting.css +78 -0
  294. package/src/syntax-highlighting.ts +477 -0
  295. package/src/syntax-tokens.ts +33 -0
  296. package/src/testing.ts +307 -0
  297. package/src/tokens.css +3 -0
  298. package/src/typography.css +86 -0
  299. package/vendor/evilcharts/LICENSE +21 -0
  300. package/vendor/evilcharts/UPSTREAM.md +14 -0
  301. package/vendor/platform-marks/LICENSE +30 -0
  302. package/vendor/platform-marks/UPSTREAM.md +15 -0
  303. package/vendor/platform-marks/apple.svg +1 -0
  304. package/vendor/platform-marks/linux.svg +1 -0
  305. package/vendor/provider-marks/CRUSH-LICENSE.md +134 -0
  306. package/vendor/provider-marks/LICENSE +21 -0
  307. package/vendor/provider-marks/UPSTREAM.md +27 -0
  308. package/vendor/provider-marks/aider.svg +3 -0
  309. package/vendor/provider-marks/crush-heartbit.svg +8 -0
  310. package/vendor/provider-marks/lobehub/alibabacloud-color.svg +1 -0
  311. package/vendor/provider-marks/lobehub/alibabacloud.svg +1 -0
  312. package/vendor/provider-marks/lobehub/amp-color.svg +1 -0
  313. package/vendor/provider-marks/lobehub/amp.svg +1 -0
  314. package/vendor/provider-marks/lobehub/anthropic.svg +1 -0
  315. package/vendor/provider-marks/lobehub/claude-color.svg +1 -0
  316. package/vendor/provider-marks/lobehub/claude.svg +1 -0
  317. package/vendor/provider-marks/lobehub/claudecode-color.svg +1 -0
  318. package/vendor/provider-marks/lobehub/claudecode.svg +1 -0
  319. package/vendor/provider-marks/lobehub/codex-color.svg +1 -0
  320. package/vendor/provider-marks/lobehub/codex.svg +1 -0
  321. package/vendor/provider-marks/lobehub/cursor.svg +1 -0
  322. package/vendor/provider-marks/lobehub/deepseek-color.svg +1 -0
  323. package/vendor/provider-marks/lobehub/deepseek.svg +1 -0
  324. package/vendor/provider-marks/lobehub/devin-color.svg +1 -0
  325. package/vendor/provider-marks/lobehub/devin.svg +1 -0
  326. package/vendor/provider-marks/lobehub/gemini-color.svg +1 -0
  327. package/vendor/provider-marks/lobehub/gemini.svg +1 -0
  328. package/vendor/provider-marks/lobehub/geminicli-color.svg +1 -0
  329. package/vendor/provider-marks/lobehub/geminicli.svg +1 -0
  330. package/vendor/provider-marks/lobehub/goose.svg +1 -0
  331. package/vendor/provider-marks/lobehub/meta-color.svg +1 -0
  332. package/vendor/provider-marks/lobehub/meta.svg +1 -0
  333. package/vendor/provider-marks/lobehub/mistral-color.svg +1 -0
  334. package/vendor/provider-marks/lobehub/mistral.svg +1 -0
  335. package/vendor/provider-marks/lobehub/moonshot.svg +1 -0
  336. package/vendor/provider-marks/lobehub/nvidia-color.svg +1 -0
  337. package/vendor/provider-marks/lobehub/nvidia.svg +1 -0
  338. package/vendor/provider-marks/lobehub/openai.svg +1 -0
  339. package/vendor/provider-marks/lobehub/opencode.svg +1 -0
  340. package/vendor/provider-marks/lobehub/perplexity-color.svg +1 -0
  341. package/vendor/provider-marks/lobehub/perplexity.svg +1 -0
  342. package/vendor/provider-marks/lobehub/qwen-color.svg +1 -0
  343. package/vendor/provider-marks/lobehub/qwen.svg +1 -0
  344. package/vendor/provider-marks/lobehub/xai.svg +1 -0
  345. package/vendor/provider-marks/lobehub/zai.svg +1 -0
@@ -0,0 +1,389 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
4
+
5
+ import {
6
+ detectPlatform,
7
+ isKnownPlatformId,
8
+ isPlatformId,
9
+ matchDetectedPlatform,
10
+ platformLabel,
11
+ platformMark,
12
+ type PlatformId,
13
+ } from "../platforms.js";
14
+ import { platformInstallClassName as cx } from "./platform-install.stylex.js";
15
+
16
+ /** Another way to install on the same platform, such as npm or Homebrew. */
17
+ export interface PlatformInstallAlternative {
18
+ /** Names the route, such as "npm" or "Homebrew". */
19
+ readonly label: string;
20
+ readonly command: string;
21
+ /** Where the command runs, such as "Terminal" or "PowerShell". */
22
+ readonly shell?: string;
23
+ }
24
+
25
+ interface PlatformInstallTargetBase {
26
+ readonly id: PlatformId;
27
+ /** Required for ids other than `macos`, `linux`, and `windows`, which default to their display names. */
28
+ readonly label?: string;
29
+ /** Where the command runs, such as "Terminal" or "PowerShell". */
30
+ readonly shell?: string;
31
+ /** A short qualifier under the command, such as "Apple silicon" or "x86_64 and ARM64, glibc 2.34+". */
32
+ readonly note?: ReactNode;
33
+ readonly alternatives?: readonly PlatformInstallAlternative[];
34
+ }
35
+
36
+ /** A platform with a native install command. */
37
+ export interface AvailablePlatformInstallTarget extends PlatformInstallTargetBase {
38
+ readonly command: string;
39
+ readonly unavailable?: false;
40
+ readonly unavailableNote?: never;
41
+ }
42
+
43
+ /**
44
+ * A platform without a native build. The note says what to do instead
45
+ * ("Build from source", "Runs in WSL2"); a command, when given, is the route
46
+ * that note describes.
47
+ */
48
+ export interface UnavailablePlatformInstallTarget extends PlatformInstallTargetBase {
49
+ readonly unavailable: true;
50
+ readonly unavailableNote: ReactNode;
51
+ readonly command?: string;
52
+ }
53
+
54
+ export type PlatformInstallTarget = AvailablePlatformInstallTarget | UnavailablePlatformInstallTarget;
55
+
56
+ export interface PlatformInstallProps {
57
+ /** One entry per platform tab, in display order. */
58
+ readonly platforms: readonly PlatformInstallTarget[];
59
+ readonly className?: string;
60
+ /**
61
+ * The platform selected in the server render and before detection.
62
+ * Defaults to the first platform.
63
+ */
64
+ readonly defaultPlatform?: PlatformId;
65
+ /** Select the visitor's operating system after hydration when it is listed. Defaults to true. */
66
+ readonly detect?: boolean;
67
+ readonly id?: string;
68
+ /** Accessible name for the platform tabs. Defaults to "Platform". */
69
+ readonly label?: string;
70
+ }
71
+
72
+ type CopyStatus = Readonly<{ key: string; ok: boolean; subject: string }>;
73
+ type SelectionSource = "default" | "detected" | "chosen";
74
+
75
+ const copyResetMilliseconds = 2000;
76
+
77
+ function assertTargets(platforms: readonly PlatformInstallTarget[], defaultPlatform: PlatformId | undefined): void {
78
+ if (platforms.length === 0) throw new RangeError("PlatformInstall needs at least one platform.");
79
+ const seen = new Set<string>();
80
+ for (const target of platforms) {
81
+ if (!isPlatformId(target.id)) throw new RangeError(`Platform ids are lowercase slugs; received ${JSON.stringify(target.id)}.`);
82
+ if (seen.has(target.id)) throw new RangeError(`Duplicate platform id: ${target.id}.`);
83
+ seen.add(target.id);
84
+ if (target.label !== undefined ? target.label.trim() === "" : !isKnownPlatformId(target.id)) {
85
+ throw new RangeError(`Platform ${target.id} needs a label.`);
86
+ }
87
+ if (target.unavailable === true) {
88
+ if (target.unavailableNote === undefined || target.unavailableNote === null || target.unavailableNote === "") {
89
+ throw new RangeError(`Unavailable platform ${target.id} needs an unavailableNote.`);
90
+ }
91
+ if (target.command !== undefined && target.command.trim() === "") throw new RangeError(`Platform ${target.id} has a blank command.`);
92
+ } else {
93
+ if (typeof target.command !== "string" || target.command.trim() === "") throw new RangeError(`Platform ${target.id} needs a command.`);
94
+ }
95
+ for (const alternative of target.alternatives ?? []) {
96
+ if (alternative.label.trim() === "" || alternative.command.trim() === "") {
97
+ throw new RangeError(`Platform ${target.id} has an alternative without a label or command.`);
98
+ }
99
+ }
100
+ }
101
+ if (defaultPlatform !== undefined && !seen.has(defaultPlatform)) {
102
+ throw new RangeError(`defaultPlatform ${JSON.stringify(defaultPlatform)} is not one of the listed platforms.`);
103
+ }
104
+ }
105
+
106
+ function labelOf(target: PlatformInstallTarget): string {
107
+ return target.label ?? platformLabel(target.id);
108
+ }
109
+
110
+ function selectContents(element: HTMLElement | null): void {
111
+ if (element === null) return;
112
+ try {
113
+ const selection = element.ownerDocument.defaultView?.getSelection();
114
+ if (selection === null || selection === undefined) return;
115
+ const range = element.ownerDocument.createRange();
116
+ range.selectNodeContents(element);
117
+ selection.removeAllRanges();
118
+ selection.addRange(range);
119
+ } catch {
120
+ // Selection is a convenience; the failure is still announced.
121
+ }
122
+ }
123
+
124
+ async function writeClipboard(text: string, fallback: HTMLElement | null): Promise<boolean> {
125
+ try {
126
+ if (typeof navigator !== "undefined" && navigator.clipboard !== undefined) {
127
+ await navigator.clipboard.writeText(text);
128
+ return true;
129
+ }
130
+ } catch {
131
+ // Fall through to the selection route below.
132
+ }
133
+ // Clipboard access is blocked or unavailable: select the command so the
134
+ // visitor can copy it by hand, and try the legacy copy command once.
135
+ selectContents(fallback);
136
+ try {
137
+ return fallback?.ownerDocument.execCommand("copy") === true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Each listed platform's mark is defined once per component as a `<symbol>`
145
+ * and drawn by reference, so the tab and the no-script panel label do not
146
+ * repeat the full path (the Tux mark alone is several kilobytes).
147
+ */
148
+ function PlatformMarkSymbols({ ids, symbolId }: Readonly<{ ids: readonly PlatformId[]; symbolId: (id: PlatformId) => string }>) {
149
+ return (
150
+ <svg aria-hidden="true" className={cx(["markSymbols"])} focusable="false" xmlns="http://www.w3.org/2000/svg">
151
+ {ids.map((id) => {
152
+ const mark = platformMark(id);
153
+ return <symbol id={symbolId(id)} key={id} viewBox={mark.viewBox}><path d={mark.path} /></symbol>;
154
+ })}
155
+ </svg>
156
+ );
157
+ }
158
+
159
+ /** The decorative PlatformIcon, drawn from the component's shared symbol. */
160
+ function PlatformMarkUse({ platform, symbolId }: Readonly<{ platform: PlatformId; symbolId: string }>) {
161
+ return (
162
+ <svg aria-hidden="true" className={cx(["icon"])} data-platform={platform} fill="currentColor" focusable="false"
163
+ viewBox={platformMark(platform).viewBox} xmlns="http://www.w3.org/2000/svg">
164
+ <use href={`#${symbolId}`} />
165
+ </svg>
166
+ );
167
+ }
168
+
169
+ function CopyGlyph({ copied }: Readonly<{ copied: boolean }>) {
170
+ return (
171
+ <svg aria-hidden="true" className={cx(["copyIcon"])} fill="none" focusable="false" stroke="currentColor"
172
+ strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} viewBox="0 0 24 24">
173
+ {copied
174
+ ? <path d="M5 12.5l4.5 4.5L19 7.5" />
175
+ : <><rect height="12" rx="2" width="12" x="8" y="8" /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" /></>}
176
+ </svg>
177
+ );
178
+ }
179
+
180
+ function CommandBlock({
181
+ caption,
182
+ command,
183
+ copyKey,
184
+ onCopy,
185
+ status,
186
+ subject,
187
+ }: Readonly<{
188
+ caption: string | undefined;
189
+ command: string;
190
+ copyKey: string;
191
+ onCopy: (key: string, command: string, subject: string, pre: HTMLElement | null) => void;
192
+ status: CopyStatus | null;
193
+ subject: string;
194
+ }>) {
195
+ const preRef = useRef<HTMLPreElement>(null);
196
+ const state = status?.key === copyKey ? (status.ok ? "copied" : "failed") : "idle";
197
+ return (
198
+ <div className={cx(["command"])} data-copy-state={state}>
199
+ <div className={cx(["commandBar", (caption === undefined || caption === "") && "commandBarEmpty"])}>
200
+ <span className={cx(["shell"])}>{caption}</span>
201
+ <button
202
+ className={cx(["copy"])}
203
+ data-copy-state={state}
204
+ onClick={() => onCopy(copyKey, command, subject, preRef.current)}
205
+ type="button"
206
+ >
207
+ <CopyGlyph copied={state === "copied"} />
208
+ <span>{state === "copied" ? "Copied" : state === "failed" ? "Select to copy" : "Copy"}</span>
209
+ <span className={cx(["status"])}> {subject}</span>
210
+ </button>
211
+ </div>
212
+ {/* Focusable so keyboard users can scroll a long command sideways. */}
213
+ <pre aria-label={subject} className={cx(["pre"])} ref={preRef} tabIndex={0}>
214
+ <code className={cx(["code"])}>{command}</code>
215
+ </pre>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Install commands for each supported platform behind one tab row with
222
+ * platform icons. The server render selects `defaultPlatform` (or the first
223
+ * platform); after hydration the visitor's operating system is selected when
224
+ * it is listed. Each command has a copy button that announces the result.
225
+ * Without JavaScript the tabs and copy buttons hide and every platform's
226
+ * commands show in sequence.
227
+ */
228
+ export function PlatformInstall({
229
+ className,
230
+ defaultPlatform,
231
+ detect = true,
232
+ id,
233
+ label = "Platform",
234
+ platforms,
235
+ }: PlatformInstallProps) {
236
+ assertTargets(platforms, defaultPlatform);
237
+ const [firstTarget] = platforms;
238
+ if (firstTarget === undefined) throw new RangeError("PlatformInstall needs at least one platform.");
239
+ const generatedId = useId();
240
+ const baseId = id ?? `hraness-platform-install${generatedId.replaceAll(/[^A-Za-z0-9_-]/gu, "")}`;
241
+ const initial = defaultPlatform ?? firstTarget.id;
242
+ const [selected, setSelected] = useState<PlatformId>(initial);
243
+ const [source, setSource] = useState<SelectionSource>("default");
244
+ const [status, setStatus] = useState<CopyStatus | null>(null);
245
+ const chosen = useRef(false);
246
+ const tabs = useRef(new Map<string, HTMLButtonElement>());
247
+ const ids = platforms.map((target) => target.id);
248
+ const idKey = ids.join("\n");
249
+ const symbolId = (platform: PlatformId) => `${baseId}-mark-${platform}`;
250
+
251
+ // A removed platform falls back to the first listed one.
252
+ const current = ids.includes(selected) ? selected : initial;
253
+
254
+ useEffect(() => {
255
+ if (!detect || chosen.current) return;
256
+ const match = matchDetectedPlatform(detectPlatform(globalThis.navigator), idKey.split("\n"));
257
+ if (match === null) return;
258
+ setSelected(match);
259
+ setSource("detected");
260
+ }, [detect, idKey]);
261
+
262
+ useEffect(() => {
263
+ if (status === null) return;
264
+ const timer = setTimeout(() => setStatus(null), copyResetMilliseconds);
265
+ return () => clearTimeout(timer);
266
+ }, [status]);
267
+
268
+ const choose = useCallback((next: PlatformId, focus: boolean) => {
269
+ chosen.current = true;
270
+ setSelected(next);
271
+ setSource("chosen");
272
+ if (focus) tabs.current.get(next)?.focus();
273
+ }, []);
274
+
275
+ const copy = useCallback((key: string, command: string, subject: string, pre: HTMLElement | null) => {
276
+ void writeClipboard(command, pre).then((ok) => setStatus({ key, ok, subject }));
277
+ }, []);
278
+
279
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
280
+ const index = ids.indexOf(current);
281
+ let next: number;
282
+ switch (event.key) {
283
+ case "ArrowRight": next = (index + 1) % ids.length; break;
284
+ case "ArrowLeft": next = (index - 1 + ids.length) % ids.length; break;
285
+ case "Home": next = 0; break;
286
+ case "End": next = ids.length - 1; break;
287
+ default: return;
288
+ }
289
+ const target = ids[next];
290
+ if (target === undefined) return;
291
+ event.preventDefault();
292
+ choose(target, true);
293
+ };
294
+
295
+ return (
296
+ <div
297
+ className={cx(["root"], className)}
298
+ data-hraness-platform-install=""
299
+ data-selected-platform={current}
300
+ data-selection-source={source}
301
+ id={id}
302
+ >
303
+ <PlatformMarkSymbols ids={ids} symbolId={symbolId} />
304
+ <div aria-label={label} className={cx(["tablist"])} onKeyDown={onKeyDown} role="tablist">
305
+ {platforms.map((target) => {
306
+ const isSelected = target.id === current;
307
+ return (
308
+ <button
309
+ aria-controls={`${baseId}-panel-${target.id}`}
310
+ aria-selected={isSelected}
311
+ className={cx(["tab", isSelected && "tabSelected"])}
312
+ data-availability={target.unavailable === true ? "unavailable" : "available"}
313
+ data-platform={target.id}
314
+ id={`${baseId}-tab-${target.id}`}
315
+ key={target.id}
316
+ onClick={() => choose(target.id, false)}
317
+ ref={(element) => {
318
+ if (element === null) tabs.current.delete(target.id);
319
+ else tabs.current.set(target.id, element);
320
+ }}
321
+ role="tab"
322
+ tabIndex={isSelected ? 0 : -1}
323
+ type="button"
324
+ >
325
+ <PlatformMarkUse platform={target.id} symbolId={symbolId(target.id)} />
326
+ <span className={cx(["tabLabel"])}>{labelOf(target)}</span>
327
+ </button>
328
+ );
329
+ })}
330
+ </div>
331
+ {platforms.map((target) => {
332
+ const name = labelOf(target);
333
+ const alternatives = target.alternatives ?? [];
334
+ return (
335
+ <div
336
+ aria-labelledby={`${baseId}-tab-${target.id}`}
337
+ className={cx(["panel"])}
338
+ data-availability={target.unavailable === true ? "unavailable" : "available"}
339
+ data-platform={target.id}
340
+ hidden={target.id !== current}
341
+ id={`${baseId}-panel-${target.id}`}
342
+ key={target.id}
343
+ role="tabpanel"
344
+ >
345
+ <div className={cx(["panelBody"])}>
346
+ <p className={cx(["panelLabel"])}>
347
+ <PlatformMarkUse platform={target.id} symbolId={symbolId(target.id)} />
348
+ <span>{name}</span>
349
+ </p>
350
+ {target.unavailable === true
351
+ ? <div className={cx(["unavailable"])}>{target.unavailableNote}</div>
352
+ : null}
353
+ {target.command === undefined ? null : (
354
+ <CommandBlock
355
+ caption={target.shell}
356
+ command={target.command}
357
+ copyKey={`${target.id}:primary`}
358
+ onCopy={copy}
359
+ status={status}
360
+ subject={`${name} install command`}
361
+ />
362
+ )}
363
+ {alternatives.length === 0 ? null : (
364
+ <ul aria-label={`Other ways to install on ${name}`} className={cx(["alternatives"])}>
365
+ {alternatives.map((alternative, index) => (
366
+ <li key={`${alternative.label}-${index}`}>
367
+ <CommandBlock
368
+ caption={[alternative.label, alternative.shell].filter(Boolean).join(" · ")}
369
+ command={alternative.command}
370
+ copyKey={`${target.id}:${index}`}
371
+ onCopy={copy}
372
+ status={status}
373
+ subject={`${name} ${alternative.label} command`}
374
+ />
375
+ </li>
376
+ ))}
377
+ </ul>
378
+ )}
379
+ {target.note === undefined ? null : <div className={cx(["note"])}>{target.note}</div>}
380
+ </div>
381
+ </div>
382
+ );
383
+ })}
384
+ <p aria-live="polite" className={cx(["status"])} role="status">
385
+ {status === null ? "" : status.ok ? `Copied the ${status.subject}.` : `Copying failed. The ${status.subject} is selected; copy it with your keyboard.`}
386
+ </p>
387
+ </div>
388
+ );
389
+ }
@@ -0,0 +1,14 @@
1
+ import * as stylex from "@stylexjs/stylex";
2
+
3
+ export const playbackTransportStyles = stylex.create({
4
+ glyph: {
5
+ "block-size": "1.5rem",
6
+ "inline-size": "1.5rem",
7
+ },
8
+ root: {
9
+ alignItems: "center",
10
+ display: "flex",
11
+ flexWrap: "wrap",
12
+ gap: "var(--space-2)",
13
+ },
14
+ });
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ import {
4
+ PlayIcon,
5
+ StopIcon,
6
+ } from "@hugeicons/core-free-icons";
7
+ import { Icon, IconButton, Spinner, Toolbar, cn } from "@hraness/ui";
8
+ import * as stylex from "@stylexjs/stylex";
9
+ import type { ReactNode, Ref } from "react";
10
+
11
+ import { playbackTransportStyles } from "./playback-transport.stylex.js";
12
+
13
+ type AccessibleName =
14
+ | { readonly "aria-label": string; readonly "aria-labelledby"?: never }
15
+ | { readonly "aria-label"?: never; readonly "aria-labelledby": string };
16
+
17
+ export type PlaybackTransportStatus = "idle" | "pending" | "playing";
18
+
19
+ export type PlaybackTransportProps = AccessibleName & {
20
+ readonly buttonAriaKeyShortcuts?: string;
21
+ readonly buttonId?: string;
22
+ readonly buttonRef?: Ref<HTMLButtonElement>;
23
+ readonly className?: string;
24
+ readonly isPlayDisabled?: boolean;
25
+ readonly onPlay: () => void;
26
+ readonly onStop: () => void;
27
+ readonly pendingLabel?: string;
28
+ readonly playLabel?: string;
29
+ readonly status: PlaybackTransportStatus;
30
+ readonly stopLabel?: string;
31
+ readonly trailingControls?: ReactNode;
32
+ };
33
+
34
+ /**
35
+ * One stable, icon-only playback command for product-owned audio behavior.
36
+ *
37
+ * The same touch-sized node starts idle playback, cancels pending startup, and
38
+ * stops active playback. Its accessible name and glyph describe the current
39
+ * command without adding a persistent visual label to compact transport chrome.
40
+ */
41
+ export function PlaybackTransport({
42
+ buttonAriaKeyShortcuts,
43
+ buttonId,
44
+ buttonRef,
45
+ className,
46
+ isPlayDisabled = false,
47
+ onPlay,
48
+ onStop,
49
+ pendingLabel = "Cancel playback start",
50
+ playLabel = "Play",
51
+ status,
52
+ stopLabel = "Stop",
53
+ trailingControls,
54
+ ...accessibleName
55
+ }: PlaybackTransportProps) {
56
+ const isPending = status === "pending";
57
+ const isIdle = status === "idle";
58
+ const commandLabel = isIdle
59
+ ? playLabel
60
+ : isPending
61
+ ? pendingLabel
62
+ : stopLabel;
63
+ const rootPresentation = stylex.props(playbackTransportStyles.root);
64
+ const glyphPresentation = stylex.props(playbackTransportStyles.glyph);
65
+
66
+ return (
67
+ <Toolbar
68
+ {...accessibleName}
69
+ className={cn(
70
+ "hraness-design-playback-transport",
71
+ rootPresentation.className,
72
+ className,
73
+ )}
74
+ data-playback-status={status}
75
+ >
76
+ <IconButton
77
+ aria-busy={isPending || undefined}
78
+ aria-label={commandLabel}
79
+ {...(buttonAriaKeyShortcuts === undefined
80
+ ? {}
81
+ : { "aria-keyshortcuts": buttonAriaKeyShortcuts })}
82
+ {...(buttonId === undefined ? {} : { id: buttonId })}
83
+ {...(buttonRef === undefined ? {} : { buttonRef })}
84
+ className="hraness-design-playback-transport__button"
85
+ data-playback-command={isIdle ? "play" : "stop"}
86
+ isDisabled={isIdle && isPlayDisabled}
87
+ onPress={() => {
88
+ if (isIdle) {
89
+ if (!isPlayDisabled) onPlay();
90
+ return;
91
+ }
92
+ onStop();
93
+ }}
94
+ size="large"
95
+ variant="primary"
96
+ >
97
+ {isPending
98
+ ? <Spinner {...glyphPresentation} />
99
+ : (
100
+ <Icon
101
+ {...(glyphPresentation.className === undefined
102
+ ? {}
103
+ : { className: glyphPresentation.className })}
104
+ icon={isIdle ? PlayIcon : StopIcon}
105
+ size={24}
106
+ />
107
+ )}
108
+ </IconButton>
109
+ {trailingControls}
110
+ </Toolbar>
111
+ );
112
+ }