@motion-proto/live-tokens 0.80.0 → 0.82.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.
- package/CHANGELOG.md +35 -0
- package/package.json +17 -1
- package/src/demo/Demo.svelte +97 -0
- package/src/demo/Demo.svelte.d.ts +7 -0
- package/src/demo/DemoBar.svelte +75 -0
- package/src/demo/FloatingTokenTags.css +284 -0
- package/src/demo/FloatingTokenTags.svelte +570 -0
- package/src/demo/LabeledSelect.svelte +243 -0
- package/src/demo/Section.svelte +46 -0
- package/src/demo/SketchSelect.svelte +58 -0
- package/src/demo/SourceLinks.svelte +156 -0
- package/src/demo/TestingLoops.svelte +876 -0
- package/src/demo/TestingLoops.svelte.d.ts +4 -0
- package/src/demo/ThemeSelect.svelte +52 -0
- package/src/demo/sections/SectionClaude.svelte +192 -0
- package/src/demo/sections/SectionFooter.svelte +67 -0
- package/src/demo/sections/SectionGetStarted.svelte +100 -0
- package/src/demo/sections/SectionHero.svelte +201 -0
- package/src/demo/sections/SectionKit.svelte +410 -0
- package/src/demo/sections/SectionLayers.svelte +54 -0
- package/src/demo/sections/SectionOffering.svelte +135 -0
- package/src/demo/sketchCarry.ts +91 -0
- package/src/editor/skill-atlas/SkillAtlas.svelte +42 -19
- package/src/editor/skill-atlas/atlasLink.ts +43 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Button from '../system/components/Button.svelte';
|
|
3
|
+
import MenuSelect from '../system/components/MenuSelect.svelte';
|
|
4
|
+
import { portal } from '../system/internal/portal';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
label: string;
|
|
8
|
+
items: { value: string; label: string }[];
|
|
9
|
+
value: string;
|
|
10
|
+
/** Trigger text when `value` matches no item. Defaults to the label. */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** Label beside the trigger instead of above it. */
|
|
13
|
+
inline?: boolean;
|
|
14
|
+
size?: 'default' | 'small';
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
onchange: (value: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
label,
|
|
22
|
+
items,
|
|
23
|
+
value,
|
|
24
|
+
placeholder = label,
|
|
25
|
+
inline = false,
|
|
26
|
+
size = 'default',
|
|
27
|
+
disabled = false,
|
|
28
|
+
error = '',
|
|
29
|
+
onchange,
|
|
30
|
+
}: Props = $props();
|
|
31
|
+
|
|
32
|
+
let open = $state(false);
|
|
33
|
+
let wrapperEl: HTMLDivElement | undefined = $state();
|
|
34
|
+
let panelEl: HTMLDivElement | undefined = $state();
|
|
35
|
+
let buttonRef: HTMLButtonElement | undefined = $state();
|
|
36
|
+
|
|
37
|
+
const uid = $props.id();
|
|
38
|
+
const labelId = `${uid}-label`;
|
|
39
|
+
const buttonId = `${uid}-button`;
|
|
40
|
+
|
|
41
|
+
const selectedLabel = $derived(items.find((item) => item.value === value)?.label ?? placeholder);
|
|
42
|
+
|
|
43
|
+
/** Deduplicated because the ghosts are keyed by label, and two items may
|
|
44
|
+
legitimately carry the same one. */
|
|
45
|
+
const ghostLabels = $derived([...new Set([placeholder, ...items.map((item) => item.label)])]);
|
|
46
|
+
|
|
47
|
+
// Button.svelte has no aria-* passthrough, so the popover semantics are
|
|
48
|
+
// set imperatively on its underlying <button> via the bindable ref.
|
|
49
|
+
$effect(() => {
|
|
50
|
+
buttonRef?.setAttribute('aria-haspopup', 'listbox');
|
|
51
|
+
buttonRef?.setAttribute('aria-expanded', String(open));
|
|
52
|
+
buttonRef?.setAttribute('id', buttonId);
|
|
53
|
+
buttonRef?.setAttribute('aria-labelledby', `${labelId} ${buttonId}`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/* Anchored below the trigger, left edges aligned; flips above when the space
|
|
57
|
+
below runs out, and clamps to the viewport on both axes. Placed rather than
|
|
58
|
+
laid out because the panel is portaled: it has no ancestor left to position
|
|
59
|
+
against. Visibility is inline rather than a state flag so the focus below
|
|
60
|
+
lands on an already-visible item (hidden elements refuse focus). */
|
|
61
|
+
function position(): void {
|
|
62
|
+
const btn = buttonRef;
|
|
63
|
+
const panel = panelEl;
|
|
64
|
+
if (!btn || !panel) return;
|
|
65
|
+
const margin = 8;
|
|
66
|
+
const br = btn.getBoundingClientRect();
|
|
67
|
+
const pr = panel.getBoundingClientRect();
|
|
68
|
+
const vw = window.innerWidth;
|
|
69
|
+
const vh = window.innerHeight;
|
|
70
|
+
|
|
71
|
+
let left = br.left;
|
|
72
|
+
if (left + pr.width > vw - margin) left = vw - margin - pr.width;
|
|
73
|
+
if (left < margin) left = margin;
|
|
74
|
+
|
|
75
|
+
let top = br.bottom + margin;
|
|
76
|
+
if (top + pr.height > vh - margin) {
|
|
77
|
+
top = br.top - margin - pr.height;
|
|
78
|
+
if (top < margin) top = margin;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
panel.style.left = `${left}px`;
|
|
82
|
+
panel.style.top = `${top}px`;
|
|
83
|
+
panel.style.visibility = 'visible';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
$effect(() => {
|
|
87
|
+
if (!open || !panelEl) return;
|
|
88
|
+
position();
|
|
89
|
+
const target =
|
|
90
|
+
panelEl.querySelector<HTMLButtonElement>('.menuselect-item.selected') ??
|
|
91
|
+
panelEl.querySelector<HTMLButtonElement>('.menuselect-item');
|
|
92
|
+
target?.focus();
|
|
93
|
+
// Fixed to the viewport, so the panel has to be re-placed against a trigger
|
|
94
|
+
// that scrolls. Capture, to catch scrolls in any pane between here and the
|
|
95
|
+
// window.
|
|
96
|
+
window.addEventListener('scroll', position, true);
|
|
97
|
+
window.addEventListener('resize', position);
|
|
98
|
+
return () => {
|
|
99
|
+
window.removeEventListener('scroll', position, true);
|
|
100
|
+
window.removeEventListener('resize', position);
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
$effect(() => {
|
|
105
|
+
if (!open) return;
|
|
106
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
107
|
+
// The panel is portaled out, so it is no longer inside the wrapper and
|
|
108
|
+
// has to be tested on its own — otherwise picking an item dismisses the
|
|
109
|
+
// list before the click reaches it.
|
|
110
|
+
const target = event.target;
|
|
111
|
+
if (!(target instanceof Node)) return;
|
|
112
|
+
if (wrapperEl?.contains(target) || panelEl?.contains(target)) return;
|
|
113
|
+
open = false;
|
|
114
|
+
};
|
|
115
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
116
|
+
if (event.key === 'Escape') {
|
|
117
|
+
open = false;
|
|
118
|
+
buttonRef?.focus();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener('pointerdown', handlePointerDown);
|
|
122
|
+
window.addEventListener('keydown', handleKeydown);
|
|
123
|
+
return () => {
|
|
124
|
+
window.removeEventListener('pointerdown', handlePointerDown);
|
|
125
|
+
window.removeEventListener('keydown', handleKeydown);
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function select(next: string): void {
|
|
130
|
+
open = false;
|
|
131
|
+
if (next === value) return;
|
|
132
|
+
onchange(next);
|
|
133
|
+
}
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<div class="labeled-select" class:inline class:small={size === 'small'} bind:this={wrapperEl}>
|
|
137
|
+
<span class="labeled-select-label" id={labelId}>{label}</span>
|
|
138
|
+
|
|
139
|
+
<div class="labeled-select-trigger">
|
|
140
|
+
<Button
|
|
141
|
+
bind:buttonRef
|
|
142
|
+
class="labeled-select-button"
|
|
143
|
+
fullWidth
|
|
144
|
+
{size}
|
|
145
|
+
variant="secondary"
|
|
146
|
+
disabled={disabled || items.length === 0}
|
|
147
|
+
icon={open ? 'fas fa-chevron-up' : 'fas fa-chevron-down'}
|
|
148
|
+
iconPosition="right"
|
|
149
|
+
onclick={() => (open = !open)}
|
|
150
|
+
>
|
|
151
|
+
{selectedLabel}
|
|
152
|
+
</Button>
|
|
153
|
+
|
|
154
|
+
<!-- Ghost copies size the trigger to its widest label so the row never
|
|
155
|
+
reflows on selection. Real Buttons, so the metrics stay exact. -->
|
|
156
|
+
{#each ghostLabels as ghost (ghost)}
|
|
157
|
+
<div class="labeled-select-ghost" aria-hidden="true" inert>
|
|
158
|
+
<Button class="labeled-select-button" {size} variant="secondary" icon="fas fa-chevron-down" iconPosition="right">
|
|
159
|
+
{ghost}
|
|
160
|
+
</Button>
|
|
161
|
+
</div>
|
|
162
|
+
{/each}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{#if open}
|
|
166
|
+
<div class="labeled-select-panel" bind:this={panelEl} use:portal>
|
|
167
|
+
<MenuSelect {items} {value} minWidth="12rem" onchange={select} />
|
|
168
|
+
</div>
|
|
169
|
+
{/if}
|
|
170
|
+
|
|
171
|
+
{#if error}
|
|
172
|
+
<span class="error" aria-live="polite">{error}</span>
|
|
173
|
+
{/if}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<style>
|
|
177
|
+
.labeled-select {
|
|
178
|
+
position: relative;
|
|
179
|
+
display: flex;
|
|
180
|
+
flex-direction: column;
|
|
181
|
+
align-items: flex-start;
|
|
182
|
+
gap: var(--space-4);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.labeled-select.inline {
|
|
186
|
+
flex-direction: row;
|
|
187
|
+
flex-wrap: wrap;
|
|
188
|
+
align-items: center;
|
|
189
|
+
gap: var(--space-8);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.labeled-select-label {
|
|
193
|
+
font-family: var(--font-mono);
|
|
194
|
+
font-size: var(--font-size-md);
|
|
195
|
+
font-weight: var(--font-weight-semibold);
|
|
196
|
+
line-height: var(--line-height-normal);
|
|
197
|
+
color: var(--text-secondary);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.labeled-select.small .labeled-select-label {
|
|
201
|
+
font-size: var(--font-size-sm);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.labeled-select-trigger {
|
|
205
|
+
position: relative;
|
|
206
|
+
display: grid;
|
|
207
|
+
grid-template-areas: 'trigger';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.labeled-select-trigger > :global(*) {
|
|
211
|
+
grid-area: trigger;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.labeled-select-trigger :global(.labeled-select-button) {
|
|
215
|
+
justify-content: space-between;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.labeled-select-ghost {
|
|
219
|
+
height: 0;
|
|
220
|
+
visibility: hidden;
|
|
221
|
+
pointer-events: none;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* Fixed and portaled to <body>: absolute positioning is clipped by any
|
|
225
|
+
ancestor overflow (the Home card) and trapped by any stacking context (the
|
|
226
|
+
demo hero), and the overflow it caused made that ancestor scrollable, so
|
|
227
|
+
moving focus into the list scrolled the page out from under it. */
|
|
228
|
+
.labeled-select-panel {
|
|
229
|
+
position: fixed;
|
|
230
|
+
top: 0;
|
|
231
|
+
left: 0;
|
|
232
|
+
z-index: 1000;
|
|
233
|
+
visibility: hidden;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.error {
|
|
237
|
+
max-width: 20rem;
|
|
238
|
+
color: var(--text-danger);
|
|
239
|
+
font-family: var(--font-sans);
|
|
240
|
+
font-size: var(--font-size-sm);
|
|
241
|
+
line-height: var(--line-height-normal);
|
|
242
|
+
}
|
|
243
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import SectionDivider from '../system/components/SectionDivider.svelte';
|
|
4
|
+
|
|
5
|
+
/** Section wrapper. `variant` picks a SectionDivider preset (lg/md/sm).
|
|
6
|
+
* Each preset is fully configured in the editor — typography, colors,
|
|
7
|
+
* background, geometry are all owned by the variant. */
|
|
8
|
+
interface Props {
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
eyebrow?: string;
|
|
12
|
+
variant?: 'lg' | 'md' | 'sm';
|
|
13
|
+
gap?: string;
|
|
14
|
+
class?: string;
|
|
15
|
+
children: Snippet;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
title,
|
|
20
|
+
description,
|
|
21
|
+
eyebrow,
|
|
22
|
+
variant = 'md',
|
|
23
|
+
gap = 'var(--space-24)',
|
|
24
|
+
class: className,
|
|
25
|
+
children,
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<section class={['demo-section', className]} style:gap>
|
|
30
|
+
<SectionDivider {title} {description} {eyebrow} {variant} />
|
|
31
|
+
{@render children()}
|
|
32
|
+
</section>
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
.demo-section {
|
|
36
|
+
grid-column: 2 / span 10;
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@media (max-width: 960px) {
|
|
42
|
+
.demo-section {
|
|
43
|
+
grid-column: 1 / -1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import LabeledSelect from './LabeledSelect.svelte';
|
|
3
|
+
import { setSketch, sketchPick, sketchStyles, unsavedSketchStyle } from '../editor/core/sketch';
|
|
4
|
+
|
|
5
|
+
let { inline = false, size = 'default', onpick }: { inline?: boolean; size?: 'default' | 'small'; onpick?: (id: string | null) => void } = $props();
|
|
6
|
+
|
|
7
|
+
// The empty string is the placeholder's value, so off needs a name of its own.
|
|
8
|
+
const OFF = 'none';
|
|
9
|
+
|
|
10
|
+
let busy = $state(false);
|
|
11
|
+
let error = $state('');
|
|
12
|
+
|
|
13
|
+
const items = $derived([
|
|
14
|
+
{ value: OFF, label: 'None' },
|
|
15
|
+
/* The style the open theme carries, when nothing in the pool names it.
|
|
16
|
+
Without the row the select reads None over a drawn page, and a visitor
|
|
17
|
+
who picks something else cannot get back. */
|
|
18
|
+
...($unsavedSketchStyle ? [{ value: $unsavedSketchStyle.id, label: $unsavedSketchStyle.label }] : []),
|
|
19
|
+
...$sketchStyles.map((style) => ({ value: style.id, label: style.label })),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/* Off is its own choice here, so the picked sketchstyle only shows while the
|
|
23
|
+
effect is on. Turning it off in the Sketchstyle view leaves that
|
|
24
|
+
sketchstyle selected there; this reads as None until it is switched back on.
|
|
25
|
+
Dials moved off every named style land on the placeholder. */
|
|
26
|
+
const selected = $derived(
|
|
27
|
+
$sketchPick.state === 'off' ? OFF
|
|
28
|
+
: $sketchPick.state === 'style' ? $sketchPick.style.id
|
|
29
|
+
: '',
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
function changeSketch(next: string) {
|
|
33
|
+
if (busy) return;
|
|
34
|
+
busy = true;
|
|
35
|
+
error = '';
|
|
36
|
+
try {
|
|
37
|
+
const id = next === OFF ? null : next;
|
|
38
|
+
setSketch(id);
|
|
39
|
+
onpick?.(id);
|
|
40
|
+
} catch (reason) {
|
|
41
|
+
error = reason instanceof Error ? reason.message : 'Could not apply that sketchstyle';
|
|
42
|
+
} finally {
|
|
43
|
+
busy = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<LabeledSelect
|
|
49
|
+
{inline}
|
|
50
|
+
{size}
|
|
51
|
+
label="Sketch"
|
|
52
|
+
{items}
|
|
53
|
+
value={selected}
|
|
54
|
+
placeholder="Custom"
|
|
55
|
+
disabled={busy}
|
|
56
|
+
{error}
|
|
57
|
+
onchange={changeSketch}
|
|
58
|
+
/>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import npmLogoUrl from '../system/assets/npm-mark-white.svg';
|
|
3
|
+
import githubLogoUrl from '../system/assets/github-mark-white.svg';
|
|
4
|
+
import { contrastTokenForBackground } from '../system/backdrop';
|
|
5
|
+
|
|
6
|
+
let { compact = false }: { compact?: boolean } = $props();
|
|
7
|
+
|
|
8
|
+
let navEl: HTMLElement | undefined = $state();
|
|
9
|
+
|
|
10
|
+
// Same rule as the kite strings in FloatingTokenTags: the marks are painted
|
|
11
|
+
// through a mask in whichever invariant token out-contrasts the page
|
|
12
|
+
// background, so they survive a theme swap or a live token edit.
|
|
13
|
+
$effect(() => {
|
|
14
|
+
if (!navEl) return;
|
|
15
|
+
const el = navEl;
|
|
16
|
+
let frame = 0;
|
|
17
|
+
let lastBackground = '';
|
|
18
|
+
const sync = () => {
|
|
19
|
+
const background = getComputedStyle(el).getPropertyValue('--page-bg').trim();
|
|
20
|
+
if (background !== lastBackground) {
|
|
21
|
+
lastBackground = background;
|
|
22
|
+
el.style.setProperty('--brand-mark-color', `var(${contrastTokenForBackground(background)})`);
|
|
23
|
+
}
|
|
24
|
+
frame = requestAnimationFrame(sync);
|
|
25
|
+
};
|
|
26
|
+
sync();
|
|
27
|
+
return () => cancelAnimationFrame(frame);
|
|
28
|
+
});
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<nav class="source-links" class:compact aria-label="Project links" bind:this={navEl}>
|
|
32
|
+
<a class="brand-link" href="https://www.npmjs.com/package/@motion-proto/live-tokens" target="_blank" rel="noopener">
|
|
33
|
+
<span class="brand-mark brand-mark--npm" style:--brand-mark-image={`url("${npmLogoUrl}")`}></span>
|
|
34
|
+
<span class="brand-text">
|
|
35
|
+
<span class="brand-caption">Published on</span>
|
|
36
|
+
<span class="brand-name">npm<span class="brand-arrow" aria-hidden="true">↗</span></span>
|
|
37
|
+
</span>
|
|
38
|
+
</a>
|
|
39
|
+
<a class="brand-link" href="https://github.com/motionproto/live-tokens" target="_blank" rel="noopener">
|
|
40
|
+
<span class="brand-mark brand-mark--github" style:--brand-mark-image={`url("${githubLogoUrl}")`}></span>
|
|
41
|
+
<span class="brand-text">
|
|
42
|
+
<span class="brand-caption">Source on</span>
|
|
43
|
+
<span class="brand-name">GitHub<span class="brand-arrow" aria-hidden="true">↗</span></span>
|
|
44
|
+
</span>
|
|
45
|
+
</a>
|
|
46
|
+
</nav>
|
|
47
|
+
|
|
48
|
+
<style>
|
|
49
|
+
.source-links {
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
gap: var(--space-48);
|
|
53
|
+
flex: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.brand-link {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: var(--space-16);
|
|
60
|
+
text-decoration: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.brand-mark {
|
|
64
|
+
/* Square chips, sized to the caption + name text stack beside them.
|
|
65
|
+
The mask URL must stay quoted: Vite inlines these SVGs as data URIs
|
|
66
|
+
holding single quotes, which an unquoted url() rejects — and a dropped
|
|
67
|
+
mask paints the whole chip as a solid block. */
|
|
68
|
+
height: calc(var(--font-size-md) * 1.1 + var(--font-size-2xl) * 1.1 + var(--space-4));
|
|
69
|
+
width: calc(var(--font-size-md) * 1.1 + var(--font-size-2xl) * 1.1 + var(--space-4));
|
|
70
|
+
display: block;
|
|
71
|
+
background-color: var(--brand-mark-color, var(--color-white));
|
|
72
|
+
-webkit-mask: var(--brand-mark-image) center / contain no-repeat;
|
|
73
|
+
mask: var(--brand-mark-image) center / contain no-repeat;
|
|
74
|
+
opacity: 0.8;
|
|
75
|
+
transition: opacity var(--duration-150, 150ms) ease;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Solid tile reads heavier than the octocat silhouette — soften and trim to match its optical weight. */
|
|
79
|
+
.brand-mark--npm {
|
|
80
|
+
border-radius: var(--radius-md);
|
|
81
|
+
transform: scale(0.86);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.brand-text {
|
|
85
|
+
display: flex;
|
|
86
|
+
flex-direction: column;
|
|
87
|
+
gap: var(--space-2, 2px);
|
|
88
|
+
line-height: 1.05;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.brand-caption {
|
|
92
|
+
font-family: var(--font-mono);
|
|
93
|
+
font-size: var(--font-size-md);
|
|
94
|
+
letter-spacing: 0.01em;
|
|
95
|
+
color: var(--text-tertiary);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.brand-name {
|
|
99
|
+
display: inline-flex;
|
|
100
|
+
align-items: baseline;
|
|
101
|
+
gap: 0.25em;
|
|
102
|
+
font-family: var(--font-sans);
|
|
103
|
+
font-size: var(--font-size-2xl);
|
|
104
|
+
font-weight: var(--font-weight-medium);
|
|
105
|
+
color: var(--text-primary);
|
|
106
|
+
transition: color var(--duration-150, 150ms) ease;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.brand-arrow {
|
|
110
|
+
position: relative;
|
|
111
|
+
top: 0.12em;
|
|
112
|
+
font-size: 1em;
|
|
113
|
+
opacity: 1;
|
|
114
|
+
transition: transform var(--duration-150, 150ms) ease;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.brand-link:hover .brand-mark {
|
|
118
|
+
opacity: 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.brand-link:hover .brand-name {
|
|
122
|
+
color: var(--text-primary);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.brand-link:hover .brand-arrow {
|
|
126
|
+
transform: translate(0.15em, -0.15em);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Compact — the in-hero copy, scaled down a step on every axis. */
|
|
130
|
+
.source-links.compact {
|
|
131
|
+
gap: var(--space-32);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.compact .brand-link {
|
|
135
|
+
gap: var(--space-12);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.compact .brand-mark {
|
|
139
|
+
height: calc(var(--font-size-sm) * 1.1 + var(--font-size-xl) * 1.1 + var(--space-2));
|
|
140
|
+
width: calc(var(--font-size-sm) * 1.1 + var(--font-size-xl) * 1.1 + var(--space-2));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.compact .brand-caption {
|
|
144
|
+
font-size: var(--font-size-sm);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.compact .brand-name {
|
|
148
|
+
font-size: var(--font-size-xl);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@media (max-width: 960px) {
|
|
152
|
+
.source-links:not(.compact) {
|
|
153
|
+
gap: var(--space-24);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
</style>
|