@playpilot/tpi 1.0.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 (101) hide show
  1. package/.github/workflows/tests.yml +22 -0
  2. package/.prettierignore +4 -0
  3. package/.prettierrc +16 -0
  4. package/README.md +38 -0
  5. package/dist/link-injections.js +7 -0
  6. package/eslint.config.js +33 -0
  7. package/index.html +11 -0
  8. package/jsconfig.json +19 -0
  9. package/package.json +35 -0
  10. package/src/app.d.ts +13 -0
  11. package/src/app.html +12 -0
  12. package/src/demo.spec.js +7 -0
  13. package/src/lib/api.js +160 -0
  14. package/src/lib/array.js +15 -0
  15. package/src/lib/auth.js +84 -0
  16. package/src/lib/constants.js +2 -0
  17. package/src/lib/enums/TrackingEvent.js +15 -0
  18. package/src/lib/fakeData.js +140 -0
  19. package/src/lib/genres.json +420 -0
  20. package/src/lib/global.css +37 -0
  21. package/src/lib/hash.js +15 -0
  22. package/src/lib/html.js +21 -0
  23. package/src/lib/index.js +1 -0
  24. package/src/lib/linkInjection.js +275 -0
  25. package/src/lib/search.js +24 -0
  26. package/src/lib/text.js +61 -0
  27. package/src/lib/tracking.js +32 -0
  28. package/src/lib/variables.css +16 -0
  29. package/src/main.js +45 -0
  30. package/src/routes/+layout.svelte +54 -0
  31. package/src/routes/+page.svelte +96 -0
  32. package/src/routes/components/AfterArticlePlaylinks.svelte +90 -0
  33. package/src/routes/components/ContextMenu.svelte +67 -0
  34. package/src/routes/components/Description.svelte +47 -0
  35. package/src/routes/components/Editorial/Alert.svelte +18 -0
  36. package/src/routes/components/Editorial/DragHandle.svelte +134 -0
  37. package/src/routes/components/Editorial/Editor.svelte +277 -0
  38. package/src/routes/components/Editorial/EditorItem.svelte +260 -0
  39. package/src/routes/components/Editorial/ManualInjection.svelte +192 -0
  40. package/src/routes/components/Editorial/PlaylinkTypeSelect.svelte +132 -0
  41. package/src/routes/components/Editorial/Search/TitleSearch.svelte +176 -0
  42. package/src/routes/components/Editorial/Switch.svelte +76 -0
  43. package/src/routes/components/Editorial/TextInput.svelte +29 -0
  44. package/src/routes/components/Genres.svelte +41 -0
  45. package/src/routes/components/Icons/IconAlign.svelte +12 -0
  46. package/src/routes/components/Icons/IconBack.svelte +3 -0
  47. package/src/routes/components/Icons/IconBookmark.svelte +3 -0
  48. package/src/routes/components/Icons/IconChevron.svelte +18 -0
  49. package/src/routes/components/Icons/IconClose.svelte +3 -0
  50. package/src/routes/components/Icons/IconContinue.svelte +3 -0
  51. package/src/routes/components/Icons/IconDots.svelte +5 -0
  52. package/src/routes/components/Icons/IconEnlarge.svelte +12 -0
  53. package/src/routes/components/Icons/IconIMDb.svelte +3 -0
  54. package/src/routes/components/Icons/IconNewTab.svelte +3 -0
  55. package/src/routes/components/Modal.svelte +106 -0
  56. package/src/routes/components/Participants.svelte +44 -0
  57. package/src/routes/components/Playlinks.svelte +155 -0
  58. package/src/routes/components/Popover.svelte +95 -0
  59. package/src/routes/components/RoundButton.svelte +38 -0
  60. package/src/routes/components/SkeletonText.svelte +33 -0
  61. package/src/routes/components/Title.svelte +180 -0
  62. package/src/routes/components/TitleModal.svelte +24 -0
  63. package/src/routes/components/TitlePopover.svelte +17 -0
  64. package/src/tests/helpers.js +18 -0
  65. package/src/tests/lib/api.test.js +162 -0
  66. package/src/tests/lib/array.test.js +14 -0
  67. package/src/tests/lib/auth.test.js +115 -0
  68. package/src/tests/lib/hash.test.js +28 -0
  69. package/src/tests/lib/html.test.js +16 -0
  70. package/src/tests/lib/linkInjection.test.js +754 -0
  71. package/src/tests/lib/search.test.js +42 -0
  72. package/src/tests/lib/text.test.js +94 -0
  73. package/src/tests/lib/tracking.test.js +71 -0
  74. package/src/tests/routes/+page.test.js +109 -0
  75. package/src/tests/routes/components/AfterArticlePlaylinks.test.js +115 -0
  76. package/src/tests/routes/components/ContextMenu.test.js +37 -0
  77. package/src/tests/routes/components/Description.test.js +58 -0
  78. package/src/tests/routes/components/Editorial/Alert.test.js +17 -0
  79. package/src/tests/routes/components/Editorial/DragHandle.test.js +55 -0
  80. package/src/tests/routes/components/Editorial/Editor.test.js +64 -0
  81. package/src/tests/routes/components/Editorial/EditorItem.test.js +142 -0
  82. package/src/tests/routes/components/Editorial/ManualInjection.test.js +114 -0
  83. package/src/tests/routes/components/Editorial/PlaylinkTypeSelect.test.js +63 -0
  84. package/src/tests/routes/components/Editorial/Search/TitleSearch.test.js +58 -0
  85. package/src/tests/routes/components/Editorial/Switch.test.js +60 -0
  86. package/src/tests/routes/components/Editorial/TextInput.test.js +30 -0
  87. package/src/tests/routes/components/Genres.test.js +37 -0
  88. package/src/tests/routes/components/Modal.test.js +84 -0
  89. package/src/tests/routes/components/Participants.test.js +33 -0
  90. package/src/tests/routes/components/Playlinks.test.js +101 -0
  91. package/src/tests/routes/components/Popover.test.js +66 -0
  92. package/src/tests/routes/components/RoundButton.test.js +35 -0
  93. package/src/tests/routes/components/SkeletonText.test.js +12 -0
  94. package/src/tests/routes/components/Title.test.js +82 -0
  95. package/src/tests/routes/components/TitleModal.test.js +33 -0
  96. package/src/tests/routes/components/TitlePopover.test.js +23 -0
  97. package/src/tests/setup.js +53 -0
  98. package/src/typedefs.js +72 -0
  99. package/static/favicon.png +0 -0
  100. package/svelte.config.js +13 -0
  101. package/vite.config.js +61 -0
@@ -0,0 +1,260 @@
1
+ <script>
2
+ import { slide } from 'svelte/transition'
3
+ import IconChevron from '../Icons/IconChevron.svelte'
4
+ import IconIMDb from '../Icons/IconIMDb.svelte'
5
+ import Switch from './Switch.svelte'
6
+ import TextInput from './TextInput.svelte'
7
+ import PlaylinkTypeSelect from './PlaylinkTypeSelect.svelte'
8
+ import Alert from './Alert.svelte'
9
+ import ContextMenu from '../ContextMenu.svelte'
10
+
11
+ /** @type {{ linkInjection: LinkInjection, onremove?: () => void, onhighlight?: (element: HTMLElement) => void }} */
12
+ const { linkInjection = $bindable(), onremove = () => null, onhighlight = () => null } = $props()
13
+
14
+ const { key, title_details, failed } = $derived(linkInjection || {})
15
+
16
+ /** @type {TitleData} */
17
+ // @ts-ignore Definitely not null
18
+ const title = /** @type {TitleData} */ $derived(title_details)
19
+
20
+ let expanded = $state(false)
21
+ let highlighted = $state(false)
22
+ /** @type {HTMLElement | null} */
23
+ let element = $state(null)
24
+
25
+ /**
26
+ * Highlight links beloning to this item in the article itself.
27
+ * @param {boolean} state
28
+ */
29
+ function toggleOnPageResultHighlight(state = true) {
30
+ const matchingElements = getMatchingElements()
31
+ matchingElements.forEach(element => {
32
+ element.classList.toggle('injection-highlight', state)
33
+ })
34
+ }
35
+
36
+ /**
37
+ * Highlight this editor when hovering links in the article itself.
38
+ * @param {MouseEvent} event
39
+ */
40
+ function setInEditorHighlight(event) {
41
+ let target = /** @type {HTMLElement | null} */ (event.target)
42
+ let injectionKey = target?.dataset.playpilotInjectionKey
43
+
44
+ if (target && !injectionKey) {
45
+ target = /** @type {HTMLElement | null} */ (target.closest(`[data-playpilot-injection-key="${key}"]`))
46
+ injectionKey = target?.dataset.playpilotInjectionKey
47
+ }
48
+
49
+ highlighted = injectionKey === key
50
+
51
+ if (element && highlighted) onhighlight(element)
52
+ }
53
+
54
+ /** @param {MouseEvent} event */
55
+ function scrollLinkIntoView(event) {
56
+ requestAnimationFrame(() => toggleOnPageResultHighlight()) // Reset highlight in case the playlink type changed
57
+
58
+ const target = /** @type {HTMLElement} */ (event.target)
59
+ if (['BUTTON', 'INPUT'].includes(target.nodeName)) return
60
+ if (target.closest('button') || target.closest('input')) return
61
+
62
+ const matchingElement = getMatchingElements()[0]
63
+ if (matchingElement) matchingElement.scrollIntoView({ behavior: 'smooth' })
64
+ }
65
+
66
+ /** @returns {Element[]} */
67
+ function getMatchingElements() {
68
+ return Array.from(document.querySelectorAll(`[data-playpilot-injection-key="${key}"]`))
69
+ }
70
+ </script>
71
+
72
+ <svelte:window on:mouseover={setInEditorHighlight} />
73
+
74
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
75
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
76
+ <div
77
+ class="item"
78
+ class:highlighted
79
+ onmouseenter={() => toggleOnPageResultHighlight(true)}
80
+ onmouseleave={() => toggleOnPageResultHighlight(false)}
81
+ onclick={scrollLinkIntoView}
82
+ bind:this={element}
83
+ out:slide|global={{ duration: 200 }}>
84
+ <div class="header">
85
+ <img class="poster" src={title.standing_poster} alt="" />
86
+
87
+ <div class="info">
88
+ <div class="title">{title.title}</div>
89
+
90
+ <div class="meta">
91
+ <span><IconIMDb /> 7.1</span>
92
+ <span>{title.year}</span>
93
+ <span>{title.type}</span>
94
+
95
+ {#if title.length}
96
+ <span data-testid="length">{title.length}m</span>
97
+ {/if}
98
+ </div>
99
+ </div>
100
+
101
+ <div class="context-menu">
102
+ <ContextMenu ariaLabel="More options">
103
+ <button class="context-menu-action" onclick={onremove}>Remove</button>
104
+ </ContextMenu>
105
+ </div>
106
+ </div>
107
+
108
+ <div class="content">
109
+ {#if failed}
110
+ <Alert>A match was found, but the link could not be injected.</Alert>
111
+ {:else}
112
+ <div class="actions">
113
+ <button class="expand" onclick={() => expanded = !expanded} aria-label="Expand" aria-expanded={expanded}>
114
+ <IconChevron {expanded} />
115
+ </button>
116
+
117
+ <Switch label="Visible" active={!linkInjection.inactive} onclick={(active) => linkInjection.inactive = !active}>
118
+ Visible
119
+ </Switch>
120
+ </div>
121
+ {/if}
122
+
123
+ {#if expanded}
124
+ <div class="expanded" transition:slide={{ duration: 100 }}>
125
+ <div class="label">Link URL</div>
126
+ <TextInput bind:value={linkInjection.playpilot_url} label="Playlink URL" />
127
+
128
+ <div class="label offset">Layout options</div>
129
+ <div class="type-select">
130
+ <PlaylinkTypeSelect {linkInjection} />
131
+ </div>
132
+ </div>
133
+ {/if}
134
+ </div>
135
+ </div>
136
+
137
+ <style>
138
+ .item {
139
+ padding: 1rem 0.5rem 1rem 0.5rem;
140
+ border-bottom: 1px solid var(--playpilot-lighter);
141
+ transition: outline-offset 100ms;
142
+ }
143
+
144
+ .item:hover,
145
+ .item.highlighted {
146
+ border-radius: 0.5rem;
147
+ border-color: transparent;
148
+ outline: 2px solid var(--playpilot-content);
149
+ outline-offset: 1px;
150
+ }
151
+
152
+ .item.highlighted {
153
+ outline: 2px solid var(--playpilot-primary);
154
+ }
155
+
156
+ .header {
157
+ display: flex;
158
+ gap: 1rem;
159
+ width: 100%;
160
+ }
161
+
162
+ .poster {
163
+ display: block;
164
+ width: 2rem;
165
+ height: 3rem;
166
+ border-radius: 0.25rem;
167
+ background: var(--playpilot-content);
168
+ }
169
+
170
+ .title {
171
+ font-size: 0.875rem;
172
+ word-break: break-word;
173
+ }
174
+
175
+ .meta {
176
+ display: flex;
177
+ gap: 0.5rem;
178
+ font-size: 0.75rem;
179
+ color: var(--playpilot-text-color-alt);
180
+ }
181
+
182
+ .meta span {
183
+ display: flex;
184
+ align-items: center;
185
+ }
186
+
187
+ .meta :global(svg) {
188
+ display: block;
189
+ margin: -0.125rem 0.125rem 0 0;
190
+ height: 1em;
191
+ }
192
+
193
+ .content {
194
+ padding-top: 1rem;
195
+ }
196
+
197
+ .context-menu {
198
+ margin: 0 -0.25rem 0 auto;
199
+ }
200
+
201
+ .context-menu-action {
202
+ appearance: none;
203
+ background: transparent;
204
+ border: 0;
205
+ padding: 1rem;
206
+ font-family: inherit;
207
+ color: var(--playpilot-text-color-alt);
208
+ cursor: pointer;
209
+ }
210
+
211
+ .context-menu-action:hover {
212
+ color: var(--playpilot-text-color);
213
+ }
214
+
215
+ .actions {
216
+ display: flex;
217
+ align-items: center;
218
+ justify-content: space-between;
219
+ width: 100%;
220
+ }
221
+
222
+ .expand {
223
+ appearance: none;
224
+ display: flex;
225
+ align-items: center;
226
+ justify-content: center;
227
+ width: 1.5rem;
228
+ height: 1.5rem;
229
+ border: 0;
230
+ border-radius: 50%;
231
+ background: var(--playpilot-content);
232
+ color: var(--playpilot-text-color);
233
+ box-shadow: var(--playpilot-shadow);
234
+ cursor: pointer;
235
+ }
236
+
237
+ .expand:hover {
238
+ filter: brightness(1.2);
239
+ }
240
+
241
+ .expanded {
242
+ padding-top: 1rem;
243
+ }
244
+
245
+ .type-select {
246
+ margin-top: 0.5rem;
247
+ }
248
+
249
+ .label {
250
+ margin-bottom: 0.25rem;
251
+ opacity: 0.75;
252
+ font-size: 0.675rem;
253
+ color: var(--playpilot-text-color-alt);
254
+ }
255
+
256
+ .offset {
257
+ margin-top: 0.75rem;
258
+ }
259
+ </style>
260
+
@@ -0,0 +1,192 @@
1
+ <script>
2
+ import { onMount } from 'svelte'
3
+ import IconBack from '../Icons/IconBack.svelte'
4
+ import RoundButton from '../RoundButton.svelte'
5
+ import Alert from './Alert.svelte'
6
+ import TextInput from './TextInput.svelte'
7
+ import TitleSearch from './Search/TitleSearch.svelte'
8
+ import { playPilotBaseUrl } from '$lib/constants'
9
+ import { generateInjectionKey } from '$lib/api'
10
+ import { decodeHtmlEntities } from '$lib/html'
11
+
12
+ /** @type {{ htmlString: string, onsave: (linkInjection: LinkInjection) => void, onclose?: () => void }} */
13
+ let { htmlString = '', onsave, onclose = () => null } = $props()
14
+
15
+ let currentSelection = $state('')
16
+ let selectionSentence = $state('')
17
+ /** @type {TitleData | null} */
18
+ let selectedTitle = $state(null)
19
+ let error = $state('')
20
+ let query = $state('')
21
+
22
+ onMount(updateSelection)
23
+
24
+ /**
25
+ * Find the user selected content on the page, if any.
26
+ * Results in a visual error if the selected content was not within the given HTML.
27
+ * @returns {void}
28
+ */
29
+ function updateSelection() {
30
+ const selection = window.getSelection()
31
+ if (!selection) return
32
+
33
+ const selectionText = selection.toString().trim()
34
+ if (!selectionText) return // No content was selected
35
+
36
+ error = ''
37
+ currentSelection = selectionText
38
+ query = currentSelection
39
+ selectionSentence = findSentenceForSelection(selection, selectionText)
40
+
41
+ const nodeContent = selection.getRangeAt(0).commonAncestorContainer.textContent
42
+ const documentTextContent = decodeHtmlEntities(htmlString)
43
+
44
+ if (!nodeContent || !documentTextContent.includes(nodeContent)) { // Selected content is not within the ALI selector
45
+ error = 'Selection was not inside of given content'
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Find the sentence that the given selected phrase is in. This is limited by the node that the text is in.
51
+ * @param {Selection} selection
52
+ * @param {string} selectionText
53
+ * @returns {string}
54
+ */
55
+ function findSentenceForSelection(selection, selectionText) {
56
+ const range = selection.getRangeAt(0)
57
+
58
+ // Get the node the text is in. If the content of the node is very short we use the parent node instead.
59
+ // This is meant for content that is inside of other elements such as <p>Some <strong>word</strong> in a sentence</p>
60
+ // If we selected "word", we'd still want the full sentence, rather than just the "word".
61
+ let node = range.startContainer
62
+ if ((node.textContent || '').length <= selectionText.length * 2 && range.startContainer.parentNode) {
63
+ node = range.startContainer.parentNode
64
+ }
65
+
66
+ if (!node) return ''
67
+
68
+ const fullText = node.textContent || ''
69
+ const startOffset = range.startOffset
70
+ const endOffset = range.endOffset
71
+
72
+ const before = fullText.slice(0, startOffset).lastIndexOf('.') // Character at start of the sentence
73
+ const after = fullText.slice(endOffset).search(/[.!?]/) // Character at end of the sentence
74
+
75
+ const sentenceStart = before === -1 ? 0 : before + 1
76
+ const sentenceEnd = after === -1 ? fullText.length : endOffset + after + 1
77
+
78
+ return fullText.slice(sentenceStart, sentenceEnd).trim()
79
+ }
80
+
81
+ /**
82
+ * @returns {void}
83
+ */
84
+ function save() {
85
+ if (!currentSelection) return
86
+ if (!selectedTitle) return
87
+
88
+ const typePath = selectedTitle.type === 'movie' ? 'movie' : 'show'
89
+ const url = playPilotBaseUrl + `/${typePath}/${selectedTitle.slug}`
90
+
91
+ /** @type {LinkInjection} */
92
+ const linkInjection = {
93
+ sid: selectedTitle.sid,
94
+ title: currentSelection,
95
+ sentence: selectionSentence,
96
+ playpilot_url: url,
97
+ key: generateInjectionKey(selectedTitle.sid),
98
+ title_details: selectedTitle,
99
+ }
100
+
101
+ onsave(linkInjection)
102
+ onclose()
103
+ }
104
+ </script>
105
+
106
+ <svelte:window onmouseup={updateSelection} />
107
+
108
+ <section class="layout">
109
+ <div class="header">
110
+ <RoundButton onclick={onclose} size="1.5rem"><IconBack /></RoundButton>
111
+ <h2>Add Playlink manually</h2>
112
+ </div>
113
+
114
+ <p>Highlight the text section in your post that you want to turn into a Playlink.</p>
115
+
116
+ <label for="text">Selected text</label>
117
+ <TextInput value={currentSelection} label="Select text on the page" name="selected-text" readonly />
118
+
119
+ {#if error}
120
+ <div class="error">
121
+ <Alert>{error}</Alert>
122
+ </div>
123
+ {/if}
124
+
125
+ <label for="text">Paired title</label>
126
+ <TitleSearch onselect={(title) => selectedTitle = title} bind:query />
127
+
128
+ <button class="save" onclick={save} disabled={!currentSelection || !selectedTitle}>
129
+ Add playlink
130
+ </button>
131
+ </section>
132
+
133
+ <style>
134
+ h2 {
135
+ margin: 0;
136
+ font-size: 1rem;
137
+ font-weight: normal
138
+ }
139
+
140
+ p,
141
+ label {
142
+ font-size: 0.75rem;
143
+ max-width: 15rem;
144
+ color: var(--playpilot-text-color-alt);
145
+ }
146
+
147
+ label {
148
+ display: block;
149
+ margin-top: 1rem;
150
+ }
151
+
152
+ .layout {
153
+ height: 100%;
154
+ display: flex;
155
+ flex-direction: column;
156
+ }
157
+
158
+ .header {
159
+ display: flex;
160
+ align-items: center;
161
+ gap: 0.5rem;
162
+ margin-bottom: 1rem;
163
+ }
164
+
165
+ .error {
166
+ margin-top: 0.5rem;
167
+ }
168
+
169
+ .save {
170
+ cursor: pointer;
171
+ appearance: none;
172
+ width: 100%;
173
+ margin-top: auto;
174
+ padding: 0.5rem;
175
+ border: 0;
176
+ border-radius: 2rem;
177
+ background: var(--playpilot-content);
178
+ transition: opacity 100ms;
179
+ font-family: inherit;
180
+ color: var(--playpilot-text-color-alt);
181
+ }
182
+
183
+ .save:not([disabled]):hover {
184
+ background: var(--playpilot-content-light);
185
+ color: var(--playpilot-text-color);
186
+ }
187
+
188
+ .save[disabled] {
189
+ cursor: default;
190
+ opacity: 0.5;
191
+ }
192
+ </style>
@@ -0,0 +1,132 @@
1
+ <script>
2
+ import { slide } from 'svelte/transition'
3
+ import IconAlign from '../Icons/IconAlign.svelte'
4
+ import Switch from './Switch.svelte'
5
+
6
+ /** @type {{ linkInjection: LinkInjection }} */
7
+ let { linkInjection } = $props()
8
+
9
+ let isAfterArticleButton = $derived(linkInjection.after_article_style === 'modal_button')
10
+ </script>
11
+
12
+ <div class="switches">
13
+ <Switch
14
+ fullwidth
15
+ active={linkInjection.in_text ?? true}
16
+ onclick={(active) => linkInjection.in_text = active}
17
+ label="In-text Playlink">
18
+ <IconAlign align="center" />
19
+ In-text Playlink
20
+ </Switch>
21
+
22
+ <Switch
23
+ fullwidth
24
+ active={linkInjection.after_article ?? false}
25
+ onclick={(active) => linkInjection.after_article = active}
26
+ label="Bottom Playlink">
27
+ <IconAlign align="bottom" />
28
+ Bottom Playlink
29
+ </Switch>
30
+ </div>
31
+
32
+ {#if linkInjection.after_article}
33
+ <div transition:slide={{ duration: 100 }}>
34
+ <div class="label">Bottom playlinks style</div>
35
+
36
+ <div
37
+ class="group"
38
+ role="listbox"
39
+ tabindex="0"
40
+ aria-label="Playlink type"
41
+ aria-activedescendant="playlinks-{isAfterArticleButton ? 'after-article' : 'in-text'}">
42
+ <button
43
+ class:active={!isAfterArticleButton}
44
+ role="option"
45
+ id="playlinks-in-text"
46
+ aria-selected={!isAfterArticleButton || null}
47
+ onclick={() => linkInjection.after_article_style = 'playlinks'}>
48
+ Playlinks sentence
49
+ </button>
50
+
51
+ <button
52
+ class:active={isAfterArticleButton}
53
+ role="option"
54
+ id="playlinks-after-article"
55
+ aria-selected={isAfterArticleButton || null}
56
+ onclick={() => linkInjection.after_article_style = 'modal_button'}>
57
+ Modal button
58
+ </button>
59
+
60
+ <div class="active-marker" class:right={isAfterArticleButton}></div>
61
+ </div>
62
+ </div>
63
+ {/if}
64
+
65
+ <style>
66
+ .switches {
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: 0.75rem;
70
+ }
71
+
72
+ button {
73
+ z-index: 1;
74
+ appearance: none;
75
+ position: relative;
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ gap: 0.5rem;
80
+ border: 0;
81
+ border-radius: 2rem;
82
+ padding: 0.5rem;
83
+ background: transparent;
84
+ font-family: var(--playpilot-font-family);
85
+ font-size: 0.65rem;
86
+ color: var(--playpilot-text-color-alt);
87
+ cursor: pointer;
88
+ }
89
+
90
+ button:hover,
91
+ button.active {
92
+ color: var(--playpilot-text-color);
93
+ }
94
+
95
+ .group {
96
+ position: relative;
97
+ display: grid;
98
+ grid-template-columns: 1fr 1fr;
99
+ gap: 0.5rem;
100
+ width: 100%;
101
+ padding: 0.25rem;
102
+ border: 0;
103
+ border-radius: 2rem;
104
+ background: var(--playpilot-light);
105
+ color: var(--playpilot-text-color-alt);
106
+ font-size: 0.75rem;
107
+ font-family: var(--playpilot-font-family);
108
+ }
109
+
110
+ .active-marker {
111
+ z-index: 0;
112
+ position: absolute;
113
+ width: calc(50% - 0.5rem);
114
+ height: calc(100% - 0.5rem);
115
+ left: 0.25rem;
116
+ top: 0.25rem;
117
+ border-radius: 2rem;
118
+ background: var(--playpilot-content);
119
+ transition: left 100ms;
120
+ }
121
+
122
+ .right {
123
+ left: calc(50% + 0.25rem);
124
+ }
125
+
126
+ .label {
127
+ margin: 0.675rem 0 0.25rem;
128
+ opacity: 0.75;
129
+ font-size: 0.675rem;
130
+ color: var(--playpilot-text-color-alt);
131
+ }
132
+ </style>