@dorsk/tsumikit 0.35.1 → 0.37.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 (34) hide show
  1. package/README.md +26 -5
  2. package/dist/cap-bar.d.ts +8 -0
  3. package/dist/cap-bar.js +43 -0
  4. package/dist/components/atoms/Gauge.svelte +165 -0
  5. package/dist/components/atoms/Gauge.svelte.d.ts +24 -0
  6. package/dist/components/atoms/Select.svelte +112 -4
  7. package/dist/components/atoms/Select.svelte.d.ts +12 -0
  8. package/dist/components/layouts/MasterDetail.svelte +220 -0
  9. package/dist/components/layouts/MasterDetail.svelte.d.ts +22 -0
  10. package/dist/components/molecules/CapBar.svelte +254 -0
  11. package/dist/components/molecules/CapBar.svelte.d.ts +33 -0
  12. package/dist/components/molecules/Drawer.svelte +299 -0
  13. package/dist/components/molecules/Drawer.svelte.d.ts +35 -0
  14. package/dist/components/molecules/Fieldset.svelte +183 -0
  15. package/dist/components/molecules/Fieldset.svelte.d.ts +22 -0
  16. package/dist/components/molecules/GitRef.svelte +174 -0
  17. package/dist/components/molecules/GitRef.svelte.d.ts +30 -0
  18. package/dist/components/molecules/Menu.svelte +24 -1
  19. package/dist/components/molecules/Menu.svelte.d.ts +5 -0
  20. package/dist/components/molecules/RadioGroup.svelte +120 -14
  21. package/dist/components/molecules/RadioGroup.svelte.d.ts +6 -0
  22. package/dist/components/molecules/SectionHeader.svelte +35 -1
  23. package/dist/components/molecules/SectionHeader.svelte.d.ts +3 -0
  24. package/dist/components/molecules/Timestamp.svelte +7 -5
  25. package/dist/components/molecules/Timestamp.svelte.d.ts +2 -2
  26. package/dist/components/molecules/WorkingDir.svelte +134 -0
  27. package/dist/components/molecules/WorkingDir.svelte.d.ts +21 -0
  28. package/dist/index.d.ts +9 -0
  29. package/dist/index.js +8 -0
  30. package/dist/timestamp.d.ts +11 -5
  31. package/dist/timestamp.js +16 -2
  32. package/dist/working-dir.d.ts +12 -0
  33. package/dist/working-dir.js +38 -0
  34. package/package.json +10 -2
@@ -0,0 +1,299 @@
1
+ <script lang="ts">
2
+ // Side panel on the native <dialog>: top layer, focus trap, inert background,
3
+ // focus restoration and Escape come from the platform. Adds click-outside,
4
+ // body scroll lock, an optional page-nav column and a sticky footer.
5
+ import type { Snippet } from 'svelte';
6
+ import { browser } from '../../env';
7
+ import Icon, { type IconName } from '../atoms/Icon.svelte';
8
+ import IconButton from './IconButton.svelte';
9
+
10
+ let {
11
+ open = $bindable(),
12
+ side = 'right',
13
+ width = '620px',
14
+ navWidth = '150px',
15
+ title,
16
+ page,
17
+ icon,
18
+ onclose,
19
+ closeLabel = 'Close panel',
20
+ header,
21
+ nav,
22
+ navMobile,
23
+ children,
24
+ footer,
25
+ class: className,
26
+ style
27
+ }: {
28
+ /** Controlled visibility. When provided the `<dialog>` stays mounted and
29
+ * `showModal()`/`close()` follow the value; closing sets it back to false.
30
+ * Omit to open on mount (mount/unmount the component to show/hide). */
31
+ open?: boolean;
32
+ side?: 'left' | 'right';
33
+ /** Panel width on wide viewports (any CSS length); clamps to 100vw. Below
34
+ * 48rem the panel is always full-screen. */
35
+ width?: string;
36
+ /** Width of the `nav` column on wide viewports. */
37
+ navWidth?: string;
38
+ title: string;
39
+ /** Name of the current page, shown after the title in the header. */
40
+ page?: string;
41
+ /** Leading header glyph. */
42
+ icon?: IconName;
43
+ onclose?: () => void;
44
+ closeLabel?: string;
45
+ /** Replaces the whole header row (title, page, close button). */
46
+ header?: Snippet;
47
+ /** Vertical page list beside the content on wide viewports; on narrow
48
+ * viewports it becomes a horizontal strip above the content unless
49
+ * `navMobile` is provided. */
50
+ nav?: Snippet;
51
+ navMobile?: Snippet;
52
+ children: Snippet;
53
+ footer?: Snippet;
54
+ class?: string;
55
+ style?: string;
56
+ } = $props();
57
+
58
+ const titleId = `drawer-title-${Math.random().toString(36).slice(2, 8)}`;
59
+
60
+ let dialogEl = $state<HTMLDialogElement | null>(null);
61
+ const controlled = $derived(open !== undefined);
62
+ const shown = $derived(!controlled || open === true);
63
+
64
+ $effect(() => {
65
+ if (!dialogEl) return;
66
+ if (!controlled) {
67
+ dialogEl.showModal();
68
+ return;
69
+ }
70
+ if (open && !dialogEl.open) dialogEl.showModal();
71
+ else if (!open && dialogEl.open) dialogEl.close();
72
+ });
73
+
74
+ $effect(() => {
75
+ if (!browser || !shown) return;
76
+ const previous = document.body.style.overflow;
77
+ document.body.style.overflow = 'hidden';
78
+ return () => {
79
+ document.body.style.overflow = previous;
80
+ };
81
+ });
82
+
83
+ function requestClose() {
84
+ if (controlled) open = false;
85
+ onclose?.();
86
+ }
87
+
88
+ function onDialogClick(e: MouseEvent) {
89
+ if (e.target === dialogEl) requestClose();
90
+ }
91
+ </script>
92
+
93
+ <dialog
94
+ bind:this={dialogEl}
95
+ data-tsu="Drawer"
96
+ class="drawer"
97
+ data-side={side}
98
+ aria-labelledby={header ? undefined : titleId}
99
+ aria-label={header ? title : undefined}
100
+ oncancel={(e) => {
101
+ e.preventDefault();
102
+ requestClose();
103
+ }}
104
+ onclose={() => {
105
+ if (controlled) open = false;
106
+ }}
107
+ onclick={onDialogClick}
108
+ >
109
+ <div
110
+ class={['panel', className]}
111
+ class:has-nav={Boolean(nav)}
112
+ style:--drawer-w={width}
113
+ style:--drawer-nav-w={navWidth}
114
+ {style}
115
+ >
116
+ {#if header}
117
+ {@render header()}
118
+ {:else}
119
+ <div class="panel-head">
120
+ {#if icon}<span class="panel-icon"><Icon name={icon} size={18} /></span>{/if}
121
+ <span id={titleId} class="panel-title truncate">{title}</span>
122
+ {#if page}<span class="panel-page truncate">{page}</span>{/if}
123
+ <div class="spacer"></div>
124
+ <IconButton icon="x" label={closeLabel} onclick={requestClose} />
125
+ </div>
126
+ {/if}
127
+ {#if nav}
128
+ <nav class="panel-nav" class:has-mobile={Boolean(navMobile)}>{@render nav()}</nav>
129
+ {/if}
130
+ {#if navMobile}
131
+ <nav class="panel-nav-mobile">{@render navMobile()}</nav>
132
+ {/if}
133
+ <div class="panel-body">
134
+ {@render children()}
135
+ </div>
136
+ {#if footer}
137
+ <div class="panel-foot">{@render footer()}</div>
138
+ {/if}
139
+ </div>
140
+ </dialog>
141
+
142
+ <style>
143
+ .drawer {
144
+ margin: 0;
145
+ padding: 0;
146
+ border: 0;
147
+ background: none;
148
+ max-width: 100vw;
149
+ max-height: 100dvh;
150
+ width: 100%;
151
+ height: 100%;
152
+ color: var(--text);
153
+ display: flex;
154
+ justify-content: flex-end;
155
+ }
156
+ .drawer[data-side='left'] {
157
+ justify-content: flex-start;
158
+ }
159
+ .drawer::backdrop {
160
+ background: rgba(0, 0, 0, 0.55);
161
+ animation: drawer-backdrop-in 0.18s var(--ease);
162
+ }
163
+ @keyframes drawer-backdrop-in {
164
+ from {
165
+ opacity: 0;
166
+ }
167
+ }
168
+
169
+ .panel {
170
+ display: grid;
171
+ grid-template-columns: minmax(0, 1fr);
172
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
173
+ grid-template-areas:
174
+ 'head'
175
+ 'nav'
176
+ 'body'
177
+ 'foot';
178
+ width: 100vw;
179
+ height: 100dvh;
180
+ background: var(--bg-elevated);
181
+ padding-top: var(--safe-top);
182
+ padding-bottom: var(--safe-bottom);
183
+ animation: drawer-slide-right 0.2s var(--ease);
184
+ }
185
+ .drawer[data-side='left'] .panel {
186
+ animation-name: drawer-slide-left;
187
+ }
188
+ @keyframes drawer-slide-right {
189
+ from {
190
+ transform: translateX(100%);
191
+ }
192
+ }
193
+ @keyframes drawer-slide-left {
194
+ from {
195
+ transform: translateX(-100%);
196
+ }
197
+ }
198
+ @media (prefers-reduced-motion: reduce) {
199
+ .drawer::backdrop,
200
+ .panel {
201
+ animation: none;
202
+ }
203
+ }
204
+
205
+ .panel-head {
206
+ grid-area: head;
207
+ display: flex;
208
+ align-items: center;
209
+ gap: var(--sp-2);
210
+ padding: var(--sp-4);
211
+ border-bottom: 1px solid var(--border);
212
+ }
213
+ .panel-icon {
214
+ display: inline-flex;
215
+ flex: none;
216
+ color: var(--text-muted);
217
+ }
218
+ .panel-title {
219
+ font-size: var(--fs-lg);
220
+ font-weight: var(--fw-semibold);
221
+ }
222
+ .panel-page {
223
+ color: var(--text-muted);
224
+ font-size: var(--fs-sm);
225
+ }
226
+ .panel-page::before {
227
+ content: '·';
228
+ margin-right: var(--sp-2);
229
+ }
230
+
231
+ .panel-nav,
232
+ .panel-nav-mobile {
233
+ grid-area: nav;
234
+ display: flex;
235
+ gap: var(--sp-1);
236
+ padding: var(--sp-2);
237
+ border-bottom: 1px solid var(--border);
238
+ overflow-x: auto;
239
+ scrollbar-width: thin;
240
+ }
241
+ .panel-nav.has-mobile {
242
+ display: none;
243
+ }
244
+
245
+ .panel-body {
246
+ grid-area: body;
247
+ padding: var(--sp-4);
248
+ overflow-y: auto;
249
+ -webkit-overflow-scrolling: touch;
250
+ }
251
+ .panel-foot {
252
+ grid-area: foot;
253
+ display: flex;
254
+ align-items: center;
255
+ justify-content: flex-end;
256
+ gap: var(--sp-2);
257
+ padding: var(--sp-4);
258
+ border-top: 1px solid var(--border);
259
+ }
260
+
261
+ @media (min-width: 48rem) {
262
+ .panel {
263
+ width: min(var(--drawer-w), 100vw);
264
+ padding-top: 0;
265
+ padding-bottom: 0;
266
+ border-left: 1px solid var(--border-strong);
267
+ box-shadow: var(--shadow-lg);
268
+ grid-template-columns: minmax(0, 1fr);
269
+ grid-template-rows: auto minmax(0, 1fr) auto;
270
+ grid-template-areas:
271
+ 'head'
272
+ 'body'
273
+ 'foot';
274
+ }
275
+ .drawer[data-side='left'] .panel {
276
+ border-left: 0;
277
+ border-right: 1px solid var(--border-strong);
278
+ }
279
+ .panel.has-nav {
280
+ grid-template-columns: var(--drawer-nav-w) minmax(0, 1fr);
281
+ grid-template-areas:
282
+ 'head head'
283
+ 'nav body'
284
+ 'foot foot';
285
+ }
286
+ .panel-nav,
287
+ .panel-nav.has-mobile {
288
+ display: flex;
289
+ flex-direction: column;
290
+ overflow-x: hidden;
291
+ overflow-y: auto;
292
+ border-bottom: 0;
293
+ border-right: 1px solid var(--border);
294
+ }
295
+ .panel-nav-mobile {
296
+ display: none;
297
+ }
298
+ }
299
+ </style>
@@ -0,0 +1,35 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type IconName } from '../atoms/Icon.svelte';
3
+ type $$ComponentProps = {
4
+ /** Controlled visibility. When provided the `<dialog>` stays mounted and
5
+ * `showModal()`/`close()` follow the value; closing sets it back to false.
6
+ * Omit to open on mount (mount/unmount the component to show/hide). */
7
+ open?: boolean;
8
+ side?: 'left' | 'right';
9
+ /** Panel width on wide viewports (any CSS length); clamps to 100vw. Below
10
+ * 48rem the panel is always full-screen. */
11
+ width?: string;
12
+ /** Width of the `nav` column on wide viewports. */
13
+ navWidth?: string;
14
+ title: string;
15
+ /** Name of the current page, shown after the title in the header. */
16
+ page?: string;
17
+ /** Leading header glyph. */
18
+ icon?: IconName;
19
+ onclose?: () => void;
20
+ closeLabel?: string;
21
+ /** Replaces the whole header row (title, page, close button). */
22
+ header?: Snippet;
23
+ /** Vertical page list beside the content on wide viewports; on narrow
24
+ * viewports it becomes a horizontal strip above the content unless
25
+ * `navMobile` is provided. */
26
+ nav?: Snippet;
27
+ navMobile?: Snippet;
28
+ children: Snippet;
29
+ footer?: Snippet;
30
+ class?: string;
31
+ style?: string;
32
+ };
33
+ declare const Drawer: import("svelte").Component<$$ComponentProps, {}, "open">;
34
+ type Drawer = ReturnType<typeof Drawer>;
35
+ export default Drawer;
@@ -0,0 +1,183 @@
1
+ <script lang="ts">
2
+ // Bordered zone whose legend rides the top border. Real <fieldset>/<legend>
3
+ // for semantics. `droppable` turns it into an HTML5 drop target: a hovering
4
+ // drag that passes `accepts` highlights the zone and shows `dropHint`;
5
+ // `ondrop` receives the payload read from `dataTransfer` under `mime`.
6
+ // Keyboard users cannot drag — the consumer must offer a non-drag path
7
+ // (a move menu, a select) to the same action. Pointer-based DnD can drive
8
+ // the highlight through the bindable `over` prop.
9
+ import type { Snippet } from 'svelte';
10
+
11
+ type Tone = 'accent' | 'neutral' | 'strong';
12
+
13
+ let {
14
+ legend,
15
+ tone = 'accent',
16
+ dashed = true,
17
+ padding = 'md',
18
+ droppable = false,
19
+ mime = 'text/plain',
20
+ accepts,
21
+ ondrop,
22
+ dropHint,
23
+ over = $bindable(false),
24
+ disabled = false,
25
+ class: klass = '',
26
+ style = '',
27
+ children,
28
+ ...rest
29
+ }: {
30
+ legend?: string | Snippet;
31
+ tone?: Tone;
32
+ dashed?: boolean;
33
+ padding?: 'sm' | 'md' | 'lg';
34
+ droppable?: boolean;
35
+ mime?: string;
36
+ accepts?: (data: string, e: DragEvent) => boolean;
37
+ ondrop?: (data: string, e: DragEvent) => void;
38
+ dropHint?: string | Snippet;
39
+ over?: boolean;
40
+ disabled?: boolean;
41
+ class?: string;
42
+ style?: string;
43
+ children?: Snippet;
44
+ [key: string]: unknown;
45
+ } = $props();
46
+
47
+ let depth = 0;
48
+
49
+ function payload(e: DragEvent): string {
50
+ return e.dataTransfer?.getData(mime) ?? '';
51
+ }
52
+ function valid(e: DragEvent): boolean {
53
+ if (!droppable || disabled) return false;
54
+ if (!Array.from(e.dataTransfer?.types ?? []).includes(mime)) return false;
55
+ return accepts ? accepts(payload(e), e) : true;
56
+ }
57
+
58
+ function onDragEnter(e: DragEvent) {
59
+ if (!valid(e)) return;
60
+ e.preventDefault();
61
+ depth++;
62
+ if (depth === 1) over = true;
63
+ }
64
+ function onDragLeave(e: DragEvent) {
65
+ if (!droppable || disabled) return;
66
+ depth = Math.max(0, depth - 1);
67
+ if (depth === 0) over = false;
68
+ }
69
+ function onDragOver(e: DragEvent) {
70
+ if (!valid(e)) return;
71
+ e.preventDefault();
72
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
73
+ }
74
+ function onDrop(e: DragEvent) {
75
+ depth = 0;
76
+ over = false;
77
+ if (!valid(e)) return;
78
+ e.preventDefault();
79
+ ondrop?.(payload(e), e);
80
+ }
81
+ </script>
82
+
83
+ <fieldset
84
+ class="fieldset fieldset-{tone} pad-{padding} {klass}"
85
+ class:dashed
86
+ class:droppable
87
+ class:over
88
+ class:disabled
89
+ data-tsu="Fieldset"
90
+ {style}
91
+ ondragenter={onDragEnter}
92
+ ondragleave={onDragLeave}
93
+ ondragover={onDragOver}
94
+ ondrop={onDrop}
95
+ {...rest}
96
+ >
97
+ {#if legend}
98
+ <legend class="fieldset-legend">
99
+ {#if typeof legend === 'string'}{legend}{:else}{@render legend()}{/if}
100
+ </legend>
101
+ {/if}
102
+ {@render children?.()}
103
+ {#if droppable && over && dropHint}
104
+ <div class="fieldset-hint" aria-live="polite">
105
+ {#if typeof dropHint === 'string'}{dropHint}{:else}{@render dropHint()}{/if}
106
+ </div>
107
+ {/if}
108
+ </fieldset>
109
+
110
+ <style>
111
+ .fieldset {
112
+ --fieldset-border: var(--accent-dim);
113
+ --fieldset-pad: var(--sp-4);
114
+ position: relative;
115
+ min-width: 0;
116
+ margin: 0;
117
+ padding: calc(var(--fieldset-pad) + var(--sp-1)) var(--fieldset-pad) var(--fieldset-pad);
118
+ border: 2px solid var(--fieldset-border);
119
+ border-radius: var(--r-lg);
120
+ background: var(--bg);
121
+ transition:
122
+ border-color 0.12s var(--ease),
123
+ background 0.12s var(--ease);
124
+ }
125
+ .fieldset.dashed {
126
+ border-style: dashed;
127
+ }
128
+ .fieldset-neutral {
129
+ --fieldset-border: var(--border);
130
+ }
131
+ .fieldset-strong {
132
+ --fieldset-border: var(--border-strong);
133
+ }
134
+ .pad-sm {
135
+ --fieldset-pad: var(--sp-3);
136
+ }
137
+ .pad-lg {
138
+ --fieldset-pad: var(--sp-6);
139
+ }
140
+
141
+ .fieldset-legend {
142
+ position: absolute;
143
+ top: -13px;
144
+ left: 16px;
145
+ display: inline-flex;
146
+ align-items: center;
147
+ gap: var(--sp-2);
148
+ max-width: calc(100% - 32px);
149
+ padding: 0 8px;
150
+ background: var(--bg);
151
+ font-size: var(--fs-sm);
152
+ font-weight: var(--fw-semibold);
153
+ line-height: 1.5;
154
+ color: var(--text);
155
+ }
156
+
157
+ .fieldset.over {
158
+ --fieldset-border: var(--accent);
159
+ background: color-mix(in srgb, var(--accent) 6%, var(--bg));
160
+ }
161
+ .fieldset.over .fieldset-legend {
162
+ background: color-mix(in srgb, var(--accent) 6%, var(--bg));
163
+ }
164
+ .fieldset-hint {
165
+ margin-top: var(--sp-3);
166
+ padding: var(--sp-2) var(--sp-3);
167
+ border: 1px dashed var(--accent);
168
+ border-radius: var(--r-md);
169
+ font-size: var(--fs-sm);
170
+ color: var(--accent);
171
+ text-align: center;
172
+ pointer-events: none;
173
+ }
174
+ .fieldset.disabled {
175
+ opacity: 0.6;
176
+ }
177
+
178
+ @media (prefers-reduced-motion: reduce) {
179
+ .fieldset {
180
+ transition: none;
181
+ }
182
+ }
183
+ </style>
@@ -0,0 +1,22 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Tone = 'accent' | 'neutral' | 'strong';
3
+ type $$ComponentProps = {
4
+ legend?: string | Snippet;
5
+ tone?: Tone;
6
+ dashed?: boolean;
7
+ padding?: 'sm' | 'md' | 'lg';
8
+ droppable?: boolean;
9
+ mime?: string;
10
+ accepts?: (data: string, e: DragEvent) => boolean;
11
+ ondrop?: (data: string, e: DragEvent) => void;
12
+ dropHint?: string | Snippet;
13
+ over?: boolean;
14
+ disabled?: boolean;
15
+ class?: string;
16
+ style?: string;
17
+ children?: Snippet;
18
+ [key: string]: unknown;
19
+ };
20
+ declare const Fieldset: import("svelte").Component<$$ComponentProps, {}, "over">;
21
+ type Fieldset = ReturnType<typeof Fieldset>;
22
+ export default Fieldset;
@@ -0,0 +1,174 @@
1
+ <script lang="ts" module>
2
+ import type { Tone } from '../../tone';
3
+
4
+ export type PrState = 'open' | 'merged' | 'closed' | 'draft';
5
+
6
+ export type PullRequest = {
7
+ url: string;
8
+ owner?: string;
9
+ repo?: string;
10
+ number: number;
11
+ state?: PrState;
12
+ };
13
+
14
+ export type DiffStats = { additions: number; deletions: number };
15
+
16
+ export const PR_TONE: Record<PrState, Tone> = {
17
+ open: 'ok',
18
+ merged: 'accent',
19
+ closed: 'danger',
20
+ draft: 'neutral',
21
+ };
22
+
23
+ const TONE_COLOR: Record<Tone, string> = {
24
+ neutral: 'var(--text-muted)',
25
+ ok: 'var(--ok)',
26
+ success: 'var(--ok)',
27
+ warn: 'var(--warn)',
28
+ danger: 'var(--danger)',
29
+ info: 'var(--info)',
30
+ accent: 'var(--accent)',
31
+ };
32
+
33
+ export function prLabel(pr: PullRequest): string {
34
+ return pr.owner && pr.repo ? `${pr.owner}/${pr.repo}#${pr.number}` : `#${pr.number}`;
35
+ }
36
+ </script>
37
+
38
+ <script lang="ts">
39
+ import Icon from '../atoms/Icon.svelte';
40
+
41
+ let {
42
+ branch,
43
+ pr,
44
+ diff,
45
+ maxWidth = '14rem',
46
+ collapse = 'auto',
47
+ class: klass = '',
48
+ ...rest
49
+ }: {
50
+ branch?: string;
51
+ pr?: PullRequest;
52
+ diff?: DiffStats;
53
+ /** Cap on the branch chip; the name truncates with an ellipsis past it. */
54
+ maxWidth?: string;
55
+ /** `auto` drops chip text below 18rem of the nearest `.cq` container,
56
+ * `glyph` always shows icon-only; the full text stays in the tooltip. */
57
+ collapse?: 'auto' | 'never' | 'glyph';
58
+ class?: string;
59
+ [key: string]: unknown;
60
+ } = $props();
61
+
62
+ const prTone = $derived(pr ? TONE_COLOR[PR_TONE[pr.state ?? 'open']] : undefined);
63
+ const label = $derived(pr ? prLabel(pr) : '');
64
+ const prTitle = $derived(pr ? (pr.state ? `${label} · ${pr.state}` : label) : '');
65
+ </script>
66
+
67
+ {#snippet pullRequestGlyph()}
68
+ <circle cx="5" cy="6" r="3" />
69
+ <path d="M5 9v12" />
70
+ <circle cx="19" cy="18" r="3" />
71
+ <path d="m15 9-3-3 3-3" />
72
+ <path d="M12 6h5a2 2 0 0 1 2 2v7" />
73
+ {/snippet}
74
+
75
+ {#if branch || pr || diff}
76
+ <span
77
+ data-tsu="GitRef"
78
+ class="git-ref collapse-{collapse} {klass}"
79
+ style:--git-ref-max-width={maxWidth}
80
+ {...rest}
81
+ >
82
+ {#if branch}
83
+ <span class="chip branch" title={branch}>
84
+ <Icon name="fork" label="Branch" />
85
+ <span class="text">{branch}</span>
86
+ </span>
87
+ {/if}
88
+ {#if pr}
89
+ <a
90
+ class="chip pr"
91
+ href={pr.url}
92
+ target="_blank"
93
+ rel="noopener noreferrer"
94
+ title={prTitle}
95
+ style:--git-ref-tone={prTone}
96
+ >
97
+ <Icon label="Pull request">{@render pullRequestGlyph()}</Icon>
98
+ <span class="text">{label}</span>
99
+ </a>
100
+ {/if}
101
+ {#if diff}
102
+ <span class="diff" title="+{diff.additions} −{diff.deletions}">
103
+ <span class="add">+{diff.additions}</span>
104
+ <span class="del">−{diff.deletions}</span>
105
+ </span>
106
+ {/if}
107
+ </span>
108
+ {/if}
109
+
110
+ <style>
111
+ .git-ref {
112
+ display: inline-flex;
113
+ align-items: center;
114
+ gap: var(--sp-1);
115
+ min-width: 0;
116
+ font-family: var(--font-mono);
117
+ font-size: var(--fs-xs);
118
+ line-height: 1.4;
119
+ white-space: nowrap;
120
+ }
121
+ .chip {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ gap: 0.25em;
125
+ min-width: 0;
126
+ padding: 0.15rem var(--sp-2);
127
+ border-radius: var(--r-pill);
128
+ background: var(--bg-elevated-2);
129
+ color: var(--text-muted);
130
+ border: 1px solid var(--border);
131
+ text-decoration: none;
132
+ }
133
+ .branch {
134
+ max-width: var(--git-ref-max-width, 14rem);
135
+ }
136
+ .pr {
137
+ color: var(--git-ref-tone);
138
+ border-color: color-mix(in srgb, var(--git-ref-tone) 40%, transparent);
139
+ background: color-mix(in srgb, var(--git-ref-tone) 12%, transparent);
140
+ transition: border-color 0.12s var(--ease);
141
+ }
142
+ .pr:hover {
143
+ border-color: var(--git-ref-tone);
144
+ }
145
+ .pr:focus-visible {
146
+ outline: 2px solid var(--git-ref-tone);
147
+ outline-offset: 2px;
148
+ }
149
+ .text {
150
+ min-width: 0;
151
+ overflow: hidden;
152
+ text-overflow: ellipsis;
153
+ white-space: nowrap;
154
+ }
155
+ .diff {
156
+ display: inline-flex;
157
+ gap: 0.35em;
158
+ font-variant-numeric: tabular-nums;
159
+ }
160
+ .add {
161
+ color: var(--ok);
162
+ }
163
+ .del {
164
+ color: var(--danger);
165
+ }
166
+ .collapse-glyph .text {
167
+ display: none;
168
+ }
169
+ @container (max-width: 18rem) {
170
+ .collapse-auto .text {
171
+ display: none;
172
+ }
173
+ }
174
+ </style>