@heartheyouth/brandbook-toolkit 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/components/BrandFonts.jsx +10 -0
  2. package/components/BrowserCheck.jsx +39 -0
  3. package/components/BrowserCheck.module.scss +20 -0
  4. package/components/ui/Accordion.jsx +17 -0
  5. package/components/ui/Accordion.module.scss +57 -0
  6. package/components/ui/AlignGrid.jsx +37 -0
  7. package/components/ui/AlignGrid.module.scss +39 -0
  8. package/components/ui/AppIntro.jsx +14 -0
  9. package/components/ui/AppIntro.module.scss +27 -0
  10. package/components/ui/Artboard.jsx +13 -0
  11. package/components/ui/Artboard.module.scss +18 -0
  12. package/components/ui/Button.jsx +21 -0
  13. package/components/ui/Button.module.scss +55 -0
  14. package/components/ui/ColorComboSwatch.jsx +17 -0
  15. package/components/ui/ColorSwatch.jsx +15 -0
  16. package/components/ui/ColorSwatch.module.scss +22 -0
  17. package/components/ui/ColorSwatches.jsx +16 -0
  18. package/components/ui/ColorSwatches.module.scss +19 -0
  19. package/components/ui/EditablePill.jsx +32 -0
  20. package/components/ui/EditablePill.module.scss +26 -0
  21. package/components/ui/ExportButton.jsx +23 -0
  22. package/components/ui/ExportButton.module.scss +75 -0
  23. package/components/ui/Icon.jsx +13 -0
  24. package/components/ui/Icon.module.scss +13 -0
  25. package/components/ui/Layer.jsx +49 -0
  26. package/components/ui/Layer.module.scss +61 -0
  27. package/components/ui/Panel.jsx +14 -0
  28. package/components/ui/Panel.module.scss +51 -0
  29. package/components/ui/Player.jsx +55 -0
  30. package/components/ui/Player.module.scss +70 -0
  31. package/components/ui/Section.jsx +12 -0
  32. package/components/ui/Section.module.scss +20 -0
  33. package/components/ui/Select.jsx +14 -0
  34. package/components/ui/Select.module.scss +36 -0
  35. package/components/ui/SliderRow.jsx +25 -0
  36. package/components/ui/SliderRow.module.scss +78 -0
  37. package/components/ui/Tabs.jsx +28 -0
  38. package/components/ui/Tabs.module.scss +33 -0
  39. package/components/ui/TextInput.jsx +5 -0
  40. package/components/ui/TextInput.module.scss +17 -0
  41. package/components/ui/Toast.jsx +19 -0
  42. package/components/ui/Toast.module.scss +40 -0
  43. package/components/ui/Toggle.jsx +18 -0
  44. package/components/ui/Toggle.module.scss +44 -0
  45. package/components/ui/ToggleGroup.jsx +33 -0
  46. package/components/ui/ToggleGroup.module.scss +74 -0
  47. package/components/ui/UploadZone.jsx +28 -0
  48. package/components/ui/UploadZone.module.scss +58 -0
  49. package/components/ui/icons/arrow.svg +9 -0
  50. package/components/ui/icons/chat.svg +9 -0
  51. package/components/ui/icons/cross.svg +5 -0
  52. package/components/ui/icons/settings.svg +13 -0
  53. package/components/ui/index.js +26 -0
  54. package/compositor/CompositorApp.jsx +90 -0
  55. package/compositor/PlaybackControls.jsx +20 -0
  56. package/compositor/PreviewPanel.jsx +72 -0
  57. package/compositor/PreviewPanel.module.scss +46 -0
  58. package/compositor/SaveNameModal.jsx +63 -0
  59. package/compositor/SaveNameModal.module.scss +32 -0
  60. package/compositor/Sidebar.jsx +28 -0
  61. package/compositor/export/common.js +194 -0
  62. package/compositor/export/mov.js +224 -0
  63. package/compositor/export/mp4.js +67 -0
  64. package/compositor/export/opfs-writer.js +48 -0
  65. package/compositor/export/png.js +27 -0
  66. package/compositor/export/prores-worker.js +46 -0
  67. package/compositor/export/webm.js +90 -0
  68. package/compositor/hooks/useCanvasDrag.js +54 -0
  69. package/compositor/hooks/useCanvasScale.js +46 -0
  70. package/compositor/hooks/useDrawLoop.js +119 -0
  71. package/compositor/hooks/useDropZone.js +117 -0
  72. package/compositor/hooks/useExport.js +27 -0
  73. package/compositor/hooks/usePlayback.js +52 -0
  74. package/compositor/sections/CanvasSizeSection.jsx +25 -0
  75. package/compositor/sections/ExportSection.jsx +215 -0
  76. package/compositor/sections/ExportSection.module.scss +26 -0
  77. package/compositor/sections/LayerItem.jsx +18 -0
  78. package/compositor/sections/LayersSection.jsx +67 -0
  79. package/compositor/sections/LayersSection.module.scss +5 -0
  80. package/compositor/sections/PositionSection.jsx +106 -0
  81. package/compositor/sections/PositionSection.module.scss +11 -0
  82. package/compositor/store/compositorStore.js +93 -0
  83. package/compositor/store/exportStore.js +44 -0
  84. package/hooks/useDragScroll.js +103 -0
  85. package/index.js +40 -0
  86. package/lib/browserSupport.js +61 -0
  87. package/manifest.js +7 -0
  88. package/middleware.js +7 -0
  89. package/next.js +18 -0
  90. package/package.json +60 -4
  91. package/styles/_type.scss +39 -0
  92. package/styles/global.css +75 -0
  93. package/vendor/mediabunny.LICENSE +373 -0
  94. package/vendor/mediabunny.js +451 -0
  95. package/vendor/prores-wasm-encoder.LICENSE +502 -0
  96. package/vendor/prores-wasm-encoder.js +1 -0
  97. package/README.md +0 -3
@@ -0,0 +1,10 @@
1
+ // The Suisse fonts, served by the brand book (src/app/fonts.css/route.ts in product-brand-book), so
2
+ // the licensed font files don't ship in this package. A <link> and not an @import in global.css:
3
+ // Next.js bundles global CSS with other CSS, and a browser ignores an @import that isn't first.
4
+ // With `precedence`, React 19 hoists it into <head> once, wherever it's rendered. BrowserCheck
5
+ // renders it, so a tool only needs this when it doesn't use BrowserCheck
6
+ export const BRAND_FONTS_URL = 'https://brandbook.heartheyouth.com/fonts.css'
7
+
8
+ export default function BrandFonts() {
9
+ return <link rel="stylesheet" href={BRAND_FONTS_URL} precedence="default" />
10
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+ import { useEffect, useState } from 'react'
3
+ import { checkBrowserSupport, SUPPORTED_BROWSERS } from '../lib/browserSupport'
4
+ import BrandFonts from './BrandFonts'
5
+ import styles from './BrowserCheck.module.scss'
6
+
7
+ // Blocks the tool in browsers that can't run every export, so nobody builds a
8
+ // composition they can't save. See lib/browserSupport.js for what's checked.
9
+ // Also loads the brand fonts (BrandFonts), in every state, so every tool gets them.
10
+ export default function BrowserCheck({ children }) {
11
+ const [missing, setMissing] = useState(null)
12
+
13
+ useEffect(() => {
14
+ let cancelled = false
15
+ checkBrowserSupport().then(m => {
16
+ if (cancelled) return
17
+ if (m.length) console.warn('[BrowserCheck] missing:', m.join(', '))
18
+ setMissing(m)
19
+ })
20
+ return () => { cancelled = true }
21
+ }, [])
22
+
23
+ if (!missing) return <BrandFonts />
24
+ if (!missing.length) return <><BrandFonts />{children}</>
25
+
26
+ const storageOnly = missing.length === 1 && missing[0] === 'storage'
27
+ return (
28
+ <div className={styles.blocked}>
29
+ <BrandFonts />
30
+ <div>
31
+ <p className={styles.message}>
32
+ {storageOnly
33
+ ? 'This tool needs to save files to your browser’s storage while exporting, which isn’t available here. Private windows often block this. Open the tool in a normal window.'
34
+ : `This browser can’t run this tool. Use ${SUPPORTED_BROWSERS} or newer.`}
35
+ </p>
36
+ </div>
37
+ </div>
38
+ )
39
+ }
@@ -0,0 +1,20 @@
1
+ @use '../styles/type' as *;
2
+
3
+ .blocked {
4
+ flex: 1;
5
+ min-height: 100vh;
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ padding: 24px;
10
+ background: var(--page-bg);
11
+ color: var(--text);
12
+ text-align: center;
13
+ }
14
+
15
+ .message {
16
+ @include heading-3;
17
+ margin: 0;
18
+ line-height: 1.3;
19
+ max-width: 420px;
20
+ }
@@ -0,0 +1,17 @@
1
+ import styles from './Accordion.module.scss'
2
+
3
+ // Figma: Variation. Closed it shows `label`, `title` and a one-line `summary` (`title` and `summary`
4
+ // are optional), and the whole card opens it; open it shows `label` and the controls, and the label closes it. Controls inside turn
5
+ // black (--control-bg). Keep one open at a time by passing `open` from the parent.
6
+ export default function Accordion({ label, title, summary, open, onToggle, children }) {
7
+ return (
8
+ <section className={`${styles.accordion}${open ? ` ${styles.open}` : ''}`}>
9
+ <button type="button" className={styles.header} aria-expanded={open ? 'true' : 'false'} onClick={onToggle}>
10
+ <span className={styles.label}>{label}</span>
11
+ {!open && title && <span className={styles.title}>{title}</span>}
12
+ {!open && summary && <span className={styles.summary}>{summary}</span>}
13
+ </button>
14
+ {open && children}
15
+ </section>
16
+ )
17
+ }
@@ -0,0 +1,57 @@
1
+ @use '../../styles/type' as *;
2
+
3
+ .accordion {
4
+ --control-bg: var(--hty-black);
5
+ --bleed: 12px; // ColorSwatches run out to this edge
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: 8px;
9
+ min-width: 150px;
10
+ padding: 12px;
11
+ border-radius: var(--radius-control);
12
+ background: var(--hty-dark-grey);
13
+ color: var(--text);
14
+ }
15
+
16
+ // Closed, the header is the whole card (padding included), so anywhere on it opens it
17
+ .header {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: 8px;
21
+ width: calc(100% + 24px);
22
+ margin: -12px;
23
+ padding: 12px;
24
+ border: none;
25
+ border-radius: var(--radius-control);
26
+ background: none;
27
+ color: inherit;
28
+ text-align: left;
29
+ cursor: pointer;
30
+
31
+ &:focus-visible { outline: 2px solid var(--text); outline-offset: -2px; }
32
+ }
33
+
34
+ // Open, only the label row is the header; the controls keep the card's padding
35
+ .open .header {
36
+ margin-bottom: 0;
37
+ padding-bottom: 0;
38
+ }
39
+
40
+ .label {
41
+ @include label;
42
+ }
43
+
44
+ .title {
45
+ @include heading-3;
46
+ overflow: hidden;
47
+ text-overflow: ellipsis;
48
+ white-space: nowrap;
49
+ }
50
+
51
+ .summary {
52
+ @include label;
53
+ opacity: 0.2;
54
+ overflow: hidden;
55
+ text-overflow: ellipsis;
56
+ white-space: nowrap;
57
+ }
@@ -0,0 +1,37 @@
1
+ import styles from './AlignGrid.module.scss'
2
+
3
+ const CELLS = [
4
+ { h: 'left', v: 'top', title: 'Top left' },
5
+ { h: 'center', v: 'top', title: 'Top center' },
6
+ { h: 'right', v: 'top', title: 'Top right' },
7
+ { h: 'left', v: 'center', title: 'Center left' },
8
+ { h: 'center', v: 'center', title: 'Center' },
9
+ { h: 'right', v: 'center', title: 'Center right' },
10
+ { h: 'left', v: 'bottom', title: 'Bottom left' },
11
+ { h: 'center', v: 'bottom', title: 'Bottom center' },
12
+ { h: 'right', v: 'bottom', title: 'Bottom right' },
13
+ ]
14
+
15
+ // 3×3 alignment buttons. `value` ({ h, v }, or null) is the alignment to show as selected
16
+ export default function AlignGrid({ value, onAlign }) {
17
+ return (
18
+ <div className={styles.row}>
19
+ <span className={styles.label}>Align</span>
20
+ <div className={styles.grid}>
21
+ {CELLS.map(c => {
22
+ const isSelected = value?.h === c.h && value?.v === c.v
23
+ return (
24
+ <button
25
+ key={`${c.h}-${c.v}`}
26
+ type="button"
27
+ className={`${styles.cell}${isSelected ? ` ${styles.selected}` : ''}`}
28
+ title={c.title}
29
+ aria-pressed={isSelected ? 'true' : 'false'}
30
+ onClick={() => onAlign(c.h, c.v)}
31
+ />
32
+ )
33
+ })}
34
+ </div>
35
+ </div>
36
+ )
37
+ }
@@ -0,0 +1,39 @@
1
+ @use '../../styles/type' as *;
2
+
3
+ .row {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: 8px;
7
+ }
8
+
9
+ .label {
10
+ @include label;
11
+ color: var(--text);
12
+ }
13
+
14
+ .grid {
15
+ display: grid;
16
+ grid-template-columns: repeat(3, 1fr);
17
+ gap: 4px;
18
+ width: 100%;
19
+ }
20
+
21
+ .cell {
22
+ height: 16px;
23
+ padding: 0;
24
+ border: none;
25
+ border-radius: var(--radius-swatch);
26
+ background: var(--control-bg);
27
+ cursor: pointer;
28
+ transition: background .15s;
29
+
30
+ &.selected,
31
+ &:hover { background: var(--control-active-bg); }
32
+ &:focus-visible { outline: 2px solid var(--text); outline-offset: 1px; }
33
+ }
34
+
35
+ @media (hover: none) and (pointer: coarse) {
36
+ .cell { height: 30px; }
37
+ .cell:not(.selected):hover { background: var(--control-bg); }
38
+ .cell:active { background: var(--control-active-bg); }
39
+ }
@@ -0,0 +1,14 @@
1
+ import styles from './AppIntro.module.scss'
2
+
3
+ // The tool's icon and name, e.g. on the empty result area before anything is uploaded
4
+ export default function AppIntro({ icon, name, tagline }) {
5
+ return (
6
+ <div className={styles.intro}>
7
+ {icon && <img src={icon} alt="" className={styles.icon} />}
8
+ <div className={styles.name}>
9
+ <span>{name}</span>
10
+ {tagline && <span className={styles.tagline}>{tagline}</span>}
11
+ </div>
12
+ </div>
13
+ )
14
+ }
@@ -0,0 +1,27 @@
1
+ @use '../../styles/type' as *;
2
+
3
+ .intro {
4
+ display: flex;
5
+ flex-direction: column;
6
+ align-items: center;
7
+ justify-content: center;
8
+ gap: 20px;
9
+ }
10
+
11
+ .icon {
12
+ width: 100px;
13
+ height: 100px;
14
+ border-radius: var(--radius-sm);
15
+ object-fit: cover;
16
+ }
17
+
18
+ .name {
19
+ @include label;
20
+ display: flex;
21
+ flex-direction: column;
22
+ align-items: center;
23
+ color: var(--text);
24
+ text-align: center;
25
+ }
26
+
27
+ .tagline { opacity: 0.5; }
@@ -0,0 +1,13 @@
1
+ import styles from './Artboard.module.scss'
2
+
3
+ // The black card that holds the tool's canvas (Figma: Result). It takes the size of what's inside,
4
+ // so size the canvas (or give it a className/style); `overlay` is centred on top, e.g. an AppIntro
5
+ // while nothing is uploaded yet. Other props (ref, data-*, handlers) go to the card itself.
6
+ export default function Artboard({ overlay, className, children, ...props }) {
7
+ return (
8
+ <div className={`${styles.artboard}${className ? ` ${className}` : ''}`} {...props}>
9
+ {children}
10
+ {overlay && <div className={styles.overlay}>{overlay}</div>}
11
+ </div>
12
+ )
13
+ }
@@ -0,0 +1,18 @@
1
+ // Figma: Result — black, 16px corners, content clipped to them
2
+ .artboard {
3
+ position: relative;
4
+ border-radius: var(--radius-panel);
5
+ overflow: hidden;
6
+ background: var(--hty-black);
7
+
8
+ > canvas { display: block; }
9
+ }
10
+
11
+ .overlay {
12
+ position: absolute;
13
+ inset: 0;
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ pointer-events: none;
18
+ }
@@ -0,0 +1,21 @@
1
+ import Icon from './Icon'
2
+ import styles from './Button.module.scss'
3
+
4
+ // variant: 'neutral' (dark grey, white on hover), 'primary' (white).
5
+ // `small` sizes it to its text (Figma: Controls' Play/Stop); `arrow` adds the → (Figma: Button).
6
+ export default function Button({ variant = 'neutral', small, arrow, className, children, ...props }) {
7
+ const cls = [
8
+ styles.btn,
9
+ styles[variant] ?? styles.neutral,
10
+ small && styles.small,
11
+ arrow && styles.withArrow,
12
+ className,
13
+ ].filter(Boolean).join(' ')
14
+
15
+ return (
16
+ <button type="button" className={cls} {...props}>
17
+ {children}
18
+ {arrow && <Icon name="arrow" />}
19
+ </button>
20
+ )
21
+ }
@@ -0,0 +1,55 @@
1
+ @use '../../styles/type' as *;
2
+
3
+ .btn {
4
+ @include label;
5
+ width: 100%;
6
+ min-height: 40px;
7
+ display: inline-flex;
8
+ align-items: center;
9
+ justify-content: center;
10
+ gap: 8px;
11
+ padding: 8px 12px;
12
+ border: none;
13
+ border-radius: var(--radius-control);
14
+ white-space: nowrap;
15
+ cursor: pointer;
16
+ transition: background .15s, color .15s, opacity .2s;
17
+
18
+ &:disabled { opacity: 0.4; cursor: not-allowed; }
19
+ &:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
20
+ }
21
+
22
+ .neutral {
23
+ background: var(--control-bg);
24
+ color: var(--text);
25
+
26
+ &:not(:disabled):hover {
27
+ background: var(--control-active-bg);
28
+ color: var(--control-active-fg);
29
+ }
30
+ }
31
+
32
+ .primary {
33
+ background: var(--control-active-bg);
34
+ color: var(--control-active-fg);
35
+
36
+ &:not(:disabled):hover { opacity: 0.85; }
37
+ }
38
+
39
+ .small {
40
+ width: auto;
41
+ min-height: 0;
42
+ padding: 8px;
43
+ }
44
+
45
+ .withArrow {
46
+ justify-content: space-between;
47
+ padding-right: 8px;
48
+ }
49
+
50
+ @media (hover: none) and (pointer: coarse) {
51
+ .neutral:not(:disabled):hover { background: var(--control-bg); color: var(--text); }
52
+ .neutral:not(:disabled):active { background: var(--control-active-bg); color: var(--control-active-fg); }
53
+ .primary:not(:disabled):hover { opacity: 1; }
54
+ .small { padding: 11px 14px; }
55
+ }
@@ -0,0 +1,17 @@
1
+ import styles from './ColorSwatch.module.scss'
2
+
3
+ // A text color on a background color, previewed as "Aa": square, round when selected
4
+ export default function ColorComboSwatch({ background, color, label = 'Aa', selected, title, onClick }) {
5
+ return (
6
+ <button
7
+ type="button"
8
+ className={`${styles.swatch}${selected ? ` ${styles.selected}` : ''}`}
9
+ style={{ background, color }}
10
+ title={title ?? `${color} on ${background}`}
11
+ aria-pressed={selected ? 'true' : 'false'}
12
+ onClick={onClick}
13
+ >
14
+ {label}
15
+ </button>
16
+ )
17
+ }
@@ -0,0 +1,15 @@
1
+ import styles from './ColorSwatch.module.scss'
2
+
3
+ // A color to pick: square, round when selected
4
+ export default function ColorSwatch({ color, selected, title, onClick }) {
5
+ return (
6
+ <button
7
+ type="button"
8
+ className={`${styles.swatch}${selected ? ` ${styles.selected}` : ''}`}
9
+ style={{ background: color }}
10
+ title={title ?? color}
11
+ aria-pressed={selected ? 'true' : 'false'}
12
+ onClick={onClick}
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,22 @@
1
+ @use '../../styles/type' as *;
2
+
3
+ .swatch {
4
+ @include label;
5
+ display: inline-flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ flex-shrink: 0;
9
+ width: 40px;
10
+ height: 40px;
11
+ padding: 0;
12
+ border: none;
13
+ border-radius: var(--radius-swatch);
14
+ background: var(--hty-black);
15
+ color: var(--text);
16
+ text-transform: none;
17
+ cursor: pointer;
18
+ transition: border-radius .35s var(--ease-out);
19
+
20
+ &.selected { border-radius: 50%; }
21
+ &:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
22
+ }
@@ -0,0 +1,16 @@
1
+ import { useRef } from 'react'
2
+ import { useDragScroll } from '../../hooks/useDragScroll'
3
+ import styles from './ColorSwatches.module.scss'
4
+
5
+ // A row of ColorSwatch or ColorComboSwatch, 2px apart. It doesn't wrap: it scrolls sideways and runs
6
+ // to the edges of the Section or Accordion it sits in, and the mouse can drag it
7
+ export default function ColorSwatches({ label, children }) {
8
+ const rowRef = useRef(null)
9
+ useDragScroll({ ref: rowRef })
10
+
11
+ return (
12
+ <div ref={rowRef} className={styles.swatches} role="group" aria-label={label}>
13
+ {children}
14
+ </div>
15
+ )
16
+ }
@@ -0,0 +1,19 @@
1
+ // One row that scrolls sideways and runs to the edges of its Section (or Accordion), so swatches
2
+ // slide out under the panel's edge instead of wrapping. --bleed is that container's side padding.
3
+ .swatches {
4
+ display: flex;
5
+ gap: 2px;
6
+ margin: -4px calc(-1 * var(--bleed, 16px));
7
+ padding: 4px var(--bleed, 16px); // room for the focus outline, which overflow would clip
8
+ overflow-x: auto;
9
+ scroll-padding-inline: var(--bleed, 16px);
10
+ scrollbar-width: none;
11
+ -webkit-overflow-scrolling: touch;
12
+
13
+ &::-webkit-scrollbar { display: none; }
14
+ &[data-dragging] { cursor: grabbing; user-select: none; }
15
+ &[data-dragging] > * { cursor: inherit; }
16
+
17
+ // Keeps the last swatch's right padding, which a scrolling flex row drops
18
+ &::after { content: ''; flex: 0 0 1px; margin-left: -1px; }
19
+ }
@@ -0,0 +1,32 @@
1
+ import { useRef, useEffect } from 'react'
2
+ import styles from './EditablePill.module.scss'
3
+
4
+ export default function EditablePill({ value, onCommit, className }) {
5
+ const ref = useRef(null)
6
+ const prevRef = useRef(null)
7
+
8
+ useEffect(() => {
9
+ if (ref.current && document.activeElement !== ref.current) {
10
+ ref.current.textContent = value
11
+ }
12
+ }, [value])
13
+
14
+ return (
15
+ <span
16
+ ref={ref}
17
+ className={className || styles.pill}
18
+ contentEditable
19
+ suppressContentEditableWarning
20
+ spellCheck={false}
21
+ onFocus={() => {
22
+ prevRef.current = ref.current.textContent
23
+ getSelection().selectAllChildren(ref.current)
24
+ }}
25
+ onKeyDown={e => {
26
+ if (e.key === 'Enter') { e.preventDefault(); ref.current.blur(); window.getSelection().removeAllRanges() }
27
+ if (e.key === 'Escape') { ref.current.textContent = prevRef.current; ref.current.blur(); window.getSelection().removeAllRanges() }
28
+ }}
29
+ onBlur={() => onCommit(ref.current.textContent)}
30
+ />
31
+ )
32
+ }
@@ -0,0 +1,26 @@
1
+ @use '../../styles/type' as *;
2
+
3
+ // Same look as SliderRow's value (Figma: Number), but editable
4
+ .pill {
5
+ @include label;
6
+ flex-shrink: 0;
7
+ min-width: 40px;
8
+ padding: 8px;
9
+ border-radius: var(--radius-control);
10
+ background: var(--control-bg);
11
+ color: var(--text);
12
+ text-align: center;
13
+ white-space: nowrap;
14
+ font-variant-numeric: tabular-nums;
15
+ cursor: text;
16
+ outline: none;
17
+
18
+ &:focus {
19
+ background: var(--control-active-bg);
20
+ color: var(--control-active-fg);
21
+ }
22
+ }
23
+
24
+ @media (hover: none) and (pointer: coarse) {
25
+ .pill { padding: 10px 12px; min-width: 52px; }
26
+ }
@@ -0,0 +1,23 @@
1
+ import styles from './ExportButton.module.scss'
2
+
3
+ // Export action (Figma: ExportButton). `primary` is the white main export; while `rendering`,
4
+ // a green bar fills to `progress` (0–1), or pulses when progress is null.
5
+ export default function ExportButton({ primary, rendering, progress = null, children, className, ...props }) {
6
+ const pct = progress == null ? null : Math.round(Math.max(0, Math.min(1, progress)) * 100)
7
+ const cls = [
8
+ styles.btn,
9
+ primary && !rendering && styles.primary,
10
+ rendering && styles.rendering,
11
+ rendering && pct == null && styles.indeterminate,
12
+ className,
13
+ ].filter(Boolean).join(' ')
14
+
15
+ return (
16
+ <button type="button" className={cls} style={rendering ? { '--pct': `${pct ?? 100}%` } : undefined} {...props}>
17
+ {rendering && <span className={styles.fill} />}
18
+ <span className={styles.text}>{children}</span>
19
+ {/* The same text in black, clipped to the green bar, so it stays readable on both */}
20
+ {rendering && pct != null && <span className={styles.textOnFill} aria-hidden="true">{children}</span>}
21
+ </button>
22
+ )
23
+ }
@@ -0,0 +1,75 @@
1
+ @use '../../styles/type' as *;
2
+
3
+ .btn {
4
+ @include label;
5
+ position: relative;
6
+ width: 100%;
7
+ height: 40px;
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ padding: 8px 12px;
12
+ border: none;
13
+ border-radius: var(--radius-control);
14
+ overflow: hidden;
15
+ background: var(--control-bg);
16
+ color: var(--text);
17
+ white-space: nowrap;
18
+ cursor: pointer;
19
+ transition: background .15s, color .15s, opacity .2s;
20
+
21
+ &:not(:disabled):not(.rendering):hover {
22
+ background: var(--control-active-bg);
23
+ color: var(--control-active-fg);
24
+ }
25
+ &:disabled:not(.rendering) { opacity: 0.4; cursor: not-allowed; }
26
+ &:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
27
+ }
28
+
29
+ .primary {
30
+ background: var(--control-active-bg);
31
+ color: var(--control-active-fg);
32
+
33
+ &:not(:disabled):hover { opacity: 0.85; }
34
+ }
35
+
36
+ .rendering { cursor: default; }
37
+
38
+ .fill {
39
+ position: absolute;
40
+ inset: 0 auto 0 0;
41
+ width: var(--pct);
42
+ background: var(--success);
43
+ transition: width .1s linear;
44
+ }
45
+
46
+ .text,
47
+ .textOnFill {
48
+ position: relative;
49
+ }
50
+
51
+ .textOnFill {
52
+ position: absolute;
53
+ inset: 0;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ color: var(--hty-black);
58
+ clip-path: inset(0 calc(100% - var(--pct)) 0 0);
59
+ transition: clip-path .1s linear;
60
+ }
61
+
62
+ .indeterminate .fill {
63
+ opacity: 0.3;
64
+ animation: renderPulse 1.4s ease-in-out infinite;
65
+ }
66
+
67
+ @keyframes renderPulse {
68
+ 0%, 100% { opacity: 0.15; }
69
+ 50% { opacity: 0.4; }
70
+ }
71
+
72
+ @media (hover: none) and (pointer: coarse) {
73
+ .btn:not(:disabled):not(.rendering):hover { background: var(--control-bg); color: var(--text); }
74
+ .primary:not(:disabled):hover { background: var(--control-active-bg); color: var(--control-active-fg); opacity: 1; }
75
+ }
@@ -0,0 +1,13 @@
1
+ import styles from './Icon.module.scss'
2
+
3
+ // Icons from the Figma component set, drawn in the current text color.
4
+ // `rotate` turns the arrow: 0 points right, 90 down (dropdowns), -90 up.
5
+ export default function Icon({ name, size = 24, rotate = 0, className }) {
6
+ return (
7
+ <span
8
+ aria-hidden="true"
9
+ className={`${styles.icon} ${styles[name] ?? ''}${className ? ` ${className}` : ''}`}
10
+ style={{ width: size, height: size, transform: rotate ? `rotate(${rotate}deg)` : undefined }}
11
+ />
12
+ )
13
+ }
@@ -0,0 +1,13 @@
1
+ // The SVG is a mask, so the icon takes `color` from its parent (white on dark, black on white)
2
+ .icon {
3
+ display: inline-block;
4
+ flex-shrink: 0;
5
+ background-color: currentColor;
6
+ -webkit-mask: var(--icon) center / contain no-repeat;
7
+ mask: var(--icon) center / contain no-repeat;
8
+ }
9
+
10
+ .arrow { --icon: url('./icons/arrow.svg'); }
11
+ .chat { --icon: url('./icons/chat.svg'); }
12
+ .cross { --icon: url('./icons/cross.svg'); }
13
+ .settings { --icon: url('./icons/settings.svg'); }