@slidev-polls/shared 0.0.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.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @slidev-polls/shared
2
+
3
+ Shared TypeScript types, REST/SSE clients, and Vue UI primitives consumed by
4
+ the [slidev-polls](https://github.com/asm0dey/slidev-polls) suite (Slidev
5
+ addon, voter SPA, backoffice SPA).
6
+
7
+ Ships untranspiled `.ts` and `.vue` source. Intended for projects that build
8
+ with Vite or another bundler that understands TypeScript and Vue SFCs.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @slidev-polls/shared
14
+ # or
15
+ bun add @slidev-polls/shared
16
+ ```
17
+
18
+ `vue@^3.5` is a peer dependency.
19
+
20
+ ## Exports
21
+
22
+ - `@slidev-polls/shared` — types, `apiClient`, `sseClient`
23
+ - `@slidev-polls/shared/ui` — Vue UI components (`ResultsPanel`,
24
+ `AllowedOriginsField`, theme helpers)
25
+ - `@slidev-polls/shared/tokens.css` — CSS design tokens
26
+
27
+ ## License
28
+
29
+ GPL-3.0-or-later. See [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@slidev-polls/shared",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Shared types, API client, SSE client, and Vue UI primitives for the slidev-polls suite.",
6
+ "license": "GPL-3.0-or-later",
7
+ "author": "Pasha Finkelshteyn <pavel.finkelshtein@gmail.com>",
8
+ "homepage": "https://github.com/asm0dey/slidev-polls#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/asm0dey/slidev-polls.git",
12
+ "directory": "frontends/shared"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/asm0dey/slidev-polls/issues"
16
+ },
17
+ "main": "./src/index.ts",
18
+ "types": "./src/index.ts",
19
+ "exports": {
20
+ ".": "./src/index.ts",
21
+ "./ui": "./src/ui/index.ts",
22
+ "./tokens.css": "./src/ui/tokens.css"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json --noEmit",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "prepublishOnly": "bun run test && bun run build"
32
+ },
33
+ "peerDependencies": {
34
+ "vue": "^3.5.0"
35
+ },
36
+ "devDependencies": {
37
+ "@vitejs/plugin-vue": "^6.0.6",
38
+ "@vue/test-utils": "^2.4.10",
39
+ "jsdom": "^29.1.1",
40
+ "typescript": "^6.0.3",
41
+ "vite": "^8.0.11",
42
+ "vitest": "^4.1.5",
43
+ "vue": "^3.5.34"
44
+ }
45
+ }
@@ -0,0 +1,105 @@
1
+ import type { Problem, ProblemCode, PublicPollView, VoteAccepted, VoteRequest } from "./types";
2
+
3
+ export interface ApiClientOptions {
4
+ baseUrl?: string;
5
+ /** Optional injection point for tests. */
6
+ fetchImpl?: typeof fetch;
7
+ }
8
+
9
+ /**
10
+ * Thrown by {@link ApiClient} on any non-2xx response. Carries the parsed
11
+ * {@link Problem} envelope so callers can branch on a typed {@code code}
12
+ * rather than scraping HTTP status codes.
13
+ */
14
+ export class ApiError extends Error {
15
+ readonly status: number;
16
+ readonly problem: Problem | null;
17
+
18
+ constructor(status: number, problem: Problem | null, message: string) {
19
+ super(message);
20
+ this.name = "ApiError";
21
+ this.status = status;
22
+ this.problem = problem;
23
+ }
24
+
25
+ get code(): ProblemCode | undefined {
26
+ return this.problem?.code;
27
+ }
28
+
29
+ get correlationId(): string | undefined {
30
+ return this.problem?.correlationId ?? undefined;
31
+ }
32
+ }
33
+
34
+ export class ApiClient {
35
+ private readonly baseUrl: string;
36
+ private readonly fetchImpl: typeof fetch;
37
+
38
+ constructor(opts: ApiClientOptions = {}) {
39
+ this.baseUrl = (opts.baseUrl ?? "").replace(/\/$/, "");
40
+ // Browsers require window.fetch to be called with `this === window`. When we
41
+ // store the global `fetch` on an instance and later invoke it as
42
+ // `this.fetchImpl(…)`, the receiver becomes the ApiClient and Chrome rejects
43
+ // the call with "Failed to execute 'fetch' on 'Window': Illegal invocation"
44
+ // (surfaced by BUG-005). Binding to globalThis keeps the call site simple
45
+ // while preserving the test injection hook.
46
+ this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis);
47
+ }
48
+
49
+ async publicPoll(slug: string): Promise<PublicPollView> {
50
+ const res = await this.fetchImpl(
51
+ `${this.baseUrl}/api/polls/by-slug/${encodeURIComponent(slug)}`,
52
+ { credentials: "same-origin" }
53
+ );
54
+ if (!res.ok) {
55
+ throw await toApiError(res);
56
+ }
57
+ return (await res.json()) as PublicPollView;
58
+ }
59
+
60
+ async submitVote(slug: string, body: VoteRequest): Promise<VoteAccepted | null> {
61
+ const res = await this.fetchImpl(
62
+ `${this.baseUrl}/api/polls/${encodeURIComponent(slug)}/votes`,
63
+ {
64
+ method: "POST",
65
+ credentials: "same-origin",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify(body)
68
+ }
69
+ );
70
+ if (!res.ok) {
71
+ throw await toApiError(res);
72
+ }
73
+ // The 201 body is VoteAccepted; 204 is tolerated for back-compat.
74
+ if (res.status === 204) {
75
+ return null;
76
+ }
77
+ return (await res.json()) as VoteAccepted;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Parses a Problem envelope from a non-2xx response. The server always returns
83
+ * application/json for API failures, but fall back to a plain message if the
84
+ * body cannot be decoded (e.g., a gateway injected an HTML error page) so the
85
+ * caller still gets a meaningful error.
86
+ */
87
+ async function toApiError(res: Response): Promise<ApiError> {
88
+ const contentType = res.headers.get("content-type") ?? "";
89
+ if (contentType.includes("application/json")) {
90
+ try {
91
+ const body = (await res.json()) as Partial<Problem>;
92
+ if (body && typeof body.code === "string" && typeof body.message === "string") {
93
+ const problem: Problem = {
94
+ code: body.code as ProblemCode,
95
+ message: body.message,
96
+ correlationId: body.correlationId
97
+ };
98
+ return new ApiError(res.status, problem, problem.message);
99
+ }
100
+ } catch {
101
+ // fall through
102
+ }
103
+ }
104
+ return new ApiError(res.status, null, `HTTP ${res.status}`);
105
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./types";
2
+ export * from "./api-client";
3
+ export * from "./sse-client";
4
+ export * from "./ui";
@@ -0,0 +1,5 @@
1
+ declare module "*.vue" {
2
+ import type { DefineComponent } from "vue";
3
+ const component: DefineComponent<object, object, unknown>;
4
+ export default component;
5
+ }
@@ -0,0 +1,118 @@
1
+ import type { QuestionClosedEvent, SnapshotEvent, TallyDeltaEvent } from "./types";
2
+
3
+ export interface StreamHandlers {
4
+ onSnapshot: (ev: SnapshotEvent) => void;
5
+ onTally: (ev: TallyDeltaEvent) => void;
6
+ onQuestionClosed?: (ev: QuestionClosedEvent) => void;
7
+ onConnectionStateChange?: (state: "open" | "paused") => void;
8
+ }
9
+
10
+ /** Factory that produces an {@link EventSource} for a given URL. Overridable in tests. */
11
+ export type EventSourceFactory = (url: string) => EventSource;
12
+
13
+ /** Schedules {@code fn} to run after {@code ms} milliseconds; returns a cancel handle. */
14
+ export type DelayedScheduler = (fn: () => void, ms: number) => () => void;
15
+
16
+ export interface OpenPollStreamOptions {
17
+ eventSourceFactory?: EventSourceFactory;
18
+ scheduler?: DelayedScheduler;
19
+ /** Delay for the first reconnect attempt after an error. Defaults to 500ms. */
20
+ minDelayMs?: number;
21
+ /** Ceiling for the exponentially-growing reconnect delay. Defaults to 10s. */
22
+ maxDelayMs?: number;
23
+ }
24
+
25
+ const DEFAULT_MIN_DELAY_MS = 500;
26
+ const DEFAULT_MAX_DELAY_MS = 10_000;
27
+
28
+ const defaultEventSourceFactory: EventSourceFactory = (url) =>
29
+ new EventSource(url, { withCredentials: true });
30
+
31
+ const defaultScheduler: DelayedScheduler = (fn, ms) => {
32
+ const id = setTimeout(fn, ms);
33
+ return () => clearTimeout(id);
34
+ };
35
+
36
+ /**
37
+ * Subscribe to the SSE stream for a single poll. The returned callback
38
+ * unsubscribes and also cancels any pending reconnect, so callers do not have
39
+ * to track timers themselves.
40
+ *
41
+ * <p>On connection failure the client flips the state to {@code "paused"},
42
+ * closes the broken {@link EventSource}, and schedules a reconnect with
43
+ * exponential backoff starting at {@code minDelayMs} and capped at
44
+ * {@code maxDelayMs}. A successful {@code open} event resets the backoff so a
45
+ * later failure starts over at {@code minDelayMs}.
46
+ *
47
+ * <p>This matches Principle IV (Live-Reliability Over Feature Depth): the UI
48
+ * shows a visible "paused" badge during reconnect rather than throwing, and
49
+ * the backoff cap keeps a long outage from blowing up to minute-long retries.
50
+ */
51
+ export function openPollStream(
52
+ baseUrl: string,
53
+ slug: string,
54
+ handlers: StreamHandlers,
55
+ opts: OpenPollStreamOptions = {}
56
+ ): () => void {
57
+ const url = `${baseUrl.replace(/\/$/, "")}/api/polls/${encodeURIComponent(slug)}/stream`;
58
+ const esFactory = opts.eventSourceFactory ?? defaultEventSourceFactory;
59
+ const schedule = opts.scheduler ?? defaultScheduler;
60
+ const minDelay = opts.minDelayMs ?? DEFAULT_MIN_DELAY_MS;
61
+ const maxDelay = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
62
+
63
+ let currentEs: EventSource | null = null;
64
+ let cancelRetry: (() => void) | null = null;
65
+ let retryDelay = minDelay;
66
+ let stopped = false;
67
+
68
+ function connect() {
69
+ if (stopped) {
70
+ return;
71
+ }
72
+ const es = esFactory(url);
73
+ currentEs = es;
74
+
75
+ es.addEventListener("open", () => {
76
+ retryDelay = minDelay;
77
+ handlers.onConnectionStateChange?.("open");
78
+ });
79
+
80
+ es.addEventListener("error", () => {
81
+ handlers.onConnectionStateChange?.("paused");
82
+ es.close();
83
+ if (currentEs === es) {
84
+ currentEs = null;
85
+ }
86
+ const scheduledDelay = retryDelay;
87
+ retryDelay = Math.min(retryDelay * 2, maxDelay);
88
+ cancelRetry = schedule(() => {
89
+ cancelRetry = null;
90
+ connect();
91
+ }, scheduledDelay);
92
+ });
93
+
94
+ es.addEventListener("snapshot", (e) => {
95
+ handlers.onSnapshot(JSON.parse((e as MessageEvent).data) as SnapshotEvent);
96
+ });
97
+ es.addEventListener("tally", (e) => {
98
+ handlers.onTally(JSON.parse((e as MessageEvent).data) as TallyDeltaEvent);
99
+ });
100
+ es.addEventListener("question-closed", (e) => {
101
+ handlers.onQuestionClosed?.(JSON.parse((e as MessageEvent).data) as QuestionClosedEvent);
102
+ });
103
+ }
104
+
105
+ connect();
106
+
107
+ return () => {
108
+ stopped = true;
109
+ if (cancelRetry) {
110
+ cancelRetry();
111
+ cancelRetry = null;
112
+ }
113
+ if (currentEs) {
114
+ currentEs.close();
115
+ currentEs = null;
116
+ }
117
+ };
118
+ }
package/src/types.ts ADDED
@@ -0,0 +1,195 @@
1
+ // Mirrors the schemas in specs/001-polling-foundation/contracts/openapi.yaml.
2
+ // Keep this file and the OpenAPI document in lock-step so the voter, backoffice,
3
+ // and slidev-addon packages all speak the same DTOs.
4
+
5
+ export type PollStatus = "DRAFT" | "OPEN" | "CLOSED";
6
+ export type QuestionStatus = "DRAFT" | "ACTIVE" | "CLOSED";
7
+
8
+ export interface PollStyle {
9
+ primaryColor?: string;
10
+ accentColor?: string;
11
+ backgroundColor?: string;
12
+ fontFamily?: string;
13
+ layout?: "BARS" | "COLUMNS" | "DONUT";
14
+ }
15
+
16
+ export interface Option {
17
+ id: string;
18
+ label: string;
19
+ position: number;
20
+ }
21
+
22
+ export interface Question {
23
+ id: string;
24
+ prompt: string;
25
+ ordinal: number;
26
+ status: QuestionStatus;
27
+ options: Option[];
28
+ }
29
+
30
+ /** Back-compat alias for callers that imported `PollOption`; prefer `Option`. */
31
+ export type PollOption = Option;
32
+
33
+ /** Backoffice-side poll summary (GET /api/admin/polls). */
34
+ export interface Poll {
35
+ id: string;
36
+ title: string;
37
+ slug: string;
38
+ status: PollStatus;
39
+ publicUrl: string;
40
+ activeQuestionId: string | null;
41
+ }
42
+
43
+ /** Full poll with questions, options, and style (GET /api/admin/polls/{id}). */
44
+ export interface PollDetail extends Poll {
45
+ style: PollStyle;
46
+ questions: Question[];
47
+ allowedOrigins?: string[];
48
+ }
49
+
50
+ /** Public-facing poll view (GET /api/polls/by-slug/{slug}). */
51
+ export interface PublicPollView {
52
+ pollId: string;
53
+ slug: string;
54
+ title: string;
55
+ state: "WAITING" | "ACTIVE";
56
+ style: PollStyle;
57
+ activeQuestion?: Question;
58
+ alreadyVoted?: boolean;
59
+ }
60
+
61
+ /** Login payload (POST /api/admin/login). */
62
+ export interface LoginRequest {
63
+ username: string;
64
+ password: string;
65
+ }
66
+
67
+ /** Option payload inside CreateQuestionRequest. */
68
+ export interface CreateOptionRequest {
69
+ label: string;
70
+ }
71
+
72
+ /** Question payload for create/update (POST /api/admin/polls). */
73
+ export interface CreateQuestionRequest {
74
+ prompt: string;
75
+ options: CreateOptionRequest[];
76
+ }
77
+
78
+ /** Create-poll payload (POST /api/admin/polls). */
79
+ export interface CreatePollRequest {
80
+ title: string;
81
+ slug?: string;
82
+ style?: PollStyle;
83
+ questions: CreateQuestionRequest[];
84
+ allowedOrigins?: string[];
85
+ }
86
+
87
+ /** Patch-style update payload (PATCH /api/admin/polls/{id}). */
88
+ export interface UpdatePollRequest {
89
+ title?: string;
90
+ slug?: string;
91
+ questions?: CreateQuestionRequest[];
92
+ allowedOrigins?: string[];
93
+ }
94
+
95
+ /** Activate-question payload (POST /api/admin/polls/{id}/open). */
96
+ export interface ActivateQuestionRequest {
97
+ questionId: string;
98
+ }
99
+
100
+ /** Vote submission payload (POST /api/polls/{slug}/votes). */
101
+ export interface VoteRequest {
102
+ optionId: string;
103
+ voterToken: string;
104
+ }
105
+
106
+ /** Vote accepted response. */
107
+ export interface VoteAccepted {
108
+ voteId: string;
109
+ /** ISO-8601 timestamp at which the vote was persisted. */
110
+ recordedAt: string;
111
+ }
112
+
113
+ /** OpenAPI Problem error envelope. */
114
+ export type ProblemCode =
115
+ | "AUTH_REQUIRED"
116
+ | "FORBIDDEN"
117
+ | "NOT_FOUND"
118
+ | "VALIDATION_FAILED"
119
+ | "ALREADY_VOTED"
120
+ | "QUESTION_NOT_ACTIVE"
121
+ | "ACTIVATION_REJECTED"
122
+ | "SLUG_TAKEN"
123
+ | "SLUG_INVALID"
124
+ | "SLUG_RESERVED"
125
+ | "DECK_TOKEN_INVALID"
126
+ | "DECK_TOKEN_POLL_MISMATCH"
127
+ | "ORIGIN_INVALID"
128
+ | "SETUP_LOCKED"
129
+ | "USERNAME_TAKEN"
130
+ | "TRANSPORT_FAILURE";
131
+
132
+ export interface Problem {
133
+ code: ProblemCode;
134
+ message: string;
135
+ correlationId?: string;
136
+ /** Per-field validation messages, populated only on VALIDATION_FAILED. */
137
+ errors?: Record<string, string[]>;
138
+ }
139
+
140
+ /** Deck token as listed in the backoffice (GET /api/admin/polls/{id}/deck-tokens). */
141
+ export interface DeckToken {
142
+ id: string;
143
+ pollId: string;
144
+ label: string | null;
145
+ createdAt: string;
146
+ revokedAt: string | null;
147
+ }
148
+
149
+ /** Freshly minted deck token — plaintext bearer returned once (POST .../deck-tokens). */
150
+ export interface DeckTokenMinted extends DeckToken {
151
+ plaintext: string;
152
+ }
153
+
154
+ export interface MintDeckTokenRequest {
155
+ label?: string | null;
156
+ }
157
+
158
+ /** Slimmed snapshot of an active question — what the SSE `snapshot` event carries.
159
+ * Lacks `status` because the SSE stream is only emitted for the currently-active question. */
160
+ export interface SnapshotActiveQuestion {
161
+ id: string;
162
+ prompt: string;
163
+ ordinal: number;
164
+ options: Option[];
165
+ }
166
+
167
+ /** SSE "snapshot" event — full state for a poll/question, re-emitted on (re)connect. */
168
+ export interface SnapshotEvent {
169
+ pollId: string;
170
+ slug: string;
171
+ activeQuestion: SnapshotActiveQuestion | null;
172
+ tally: TallyEntry[];
173
+ emittedAt: string;
174
+ }
175
+
176
+ export interface TallyEntry {
177
+ optionId: string;
178
+ count: number;
179
+ }
180
+
181
+ /** SSE "tally" event — delta for a single option, fan-out on each accepted vote. */
182
+ export interface TallyDeltaEvent {
183
+ pollId: string;
184
+ questionId: string;
185
+ optionId: string;
186
+ count: number;
187
+ emittedAt: string;
188
+ }
189
+
190
+ /** SSE "question-closed" event. */
191
+ export interface QuestionClosedEvent {
192
+ pollId: string;
193
+ questionId: string;
194
+ emittedAt: string;
195
+ }
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import Chip from "./Chip.vue";
4
+ import IconAlert from "./IconAlert.vue";
5
+ import { validateOrigin } from "./origin-validator";
6
+
7
+ const props = defineProps<{ modelValue: string[] }>();
8
+ const emit = defineEmits<{ "update:modelValue": [v: string[]] }>();
9
+
10
+ const draft = ref("");
11
+ const error = ref<string | null>(null);
12
+
13
+ const items = computed(() =>
14
+ props.modelValue.map((o) => {
15
+ const r = validateOrigin(o);
16
+ return { raw: o, invalid: !r.ok };
17
+ })
18
+ );
19
+
20
+ function commit() {
21
+ const v = draft.value;
22
+ if (!v.trim()) {
23
+ error.value = null;
24
+ return;
25
+ }
26
+ const r = validateOrigin(v);
27
+ if (!r.ok) {
28
+ error.value = r.message;
29
+ return;
30
+ }
31
+ if (props.modelValue.includes(r.normalized)) {
32
+ error.value = `Already added`;
33
+ return;
34
+ }
35
+ emit("update:modelValue", [...props.modelValue, r.normalized]);
36
+ draft.value = "";
37
+ error.value = null;
38
+ }
39
+
40
+ function remove(idx: number) {
41
+ const next = props.modelValue.slice();
42
+ next.splice(idx, 1);
43
+ emit("update:modelValue", next);
44
+ }
45
+ </script>
46
+
47
+ <template>
48
+ <div class="sp-aof">
49
+ <div class="sp-aof-box">
50
+ <Chip
51
+ v-for="(item, i) in items"
52
+ :key="i"
53
+ :label="item.raw"
54
+ :invalid="item.invalid"
55
+ :mono="true"
56
+ data-testid="origin-chip"
57
+ @remove="remove(i)"
58
+ >
59
+ <template v-if="item.invalid" #leading>
60
+ <IconAlert />
61
+ </template>
62
+ </Chip>
63
+ <input
64
+ v-model="draft"
65
+ class="sp-aof-input"
66
+ placeholder="https://… (Enter or Tab to add)"
67
+ @keydown.enter.prevent="commit"
68
+ @blur="commit"
69
+ />
70
+ </div>
71
+ <div v-if="error" class="sp-aof-error" data-testid="aof-error" role="alert">
72
+ <IconAlert />
73
+ <span>{{ error }}</span>
74
+ </div>
75
+ </div>
76
+ </template>
77
+
78
+ <style scoped>
79
+ .sp-aof {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 8px;
83
+ }
84
+ .sp-aof-box {
85
+ border: 1px solid var(--sp-border);
86
+ border-radius: var(--sp-radius);
87
+ padding: 8px;
88
+ display: flex;
89
+ flex-wrap: wrap;
90
+ gap: 6px;
91
+ min-height: 42px;
92
+ background: var(--sp-bg);
93
+ }
94
+ .sp-aof-input {
95
+ flex: 1;
96
+ min-width: 160px;
97
+ border: 0;
98
+ outline: 0;
99
+ background: transparent;
100
+ font-size: 13px;
101
+ font-family: var(--sp-font-mono);
102
+ padding: 4px 6px;
103
+ color: var(--sp-fg);
104
+ }
105
+ .sp-aof-error {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 8px;
109
+ padding: 8px 10px;
110
+ background: var(--sp-danger-bg);
111
+ border: 1px solid var(--sp-danger);
112
+ border-radius: var(--sp-radius-sm);
113
+ font-size: 12px;
114
+ color: var(--sp-danger-fg);
115
+ }
116
+ </style>
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ variant?: "primary" | "secondary" | "ghost" | "danger";
5
+ size?: "sm" | "md";
6
+ type?: "button" | "submit" | "reset";
7
+ }>(),
8
+ { variant: "primary", size: "md", type: "button" }
9
+ );
10
+ </script>
11
+
12
+ <template>
13
+ <button :type="type" :data-variant="variant" :data-size="size" class="sp-btn">
14
+ <slot />
15
+ </button>
16
+ </template>
17
+
18
+ <style scoped>
19
+ .sp-btn {
20
+ font-family: var(--sp-font-sans);
21
+ font-weight: 500;
22
+ border-radius: var(--sp-radius);
23
+ border: 1px solid transparent;
24
+ cursor: pointer;
25
+ transition:
26
+ background var(--sp-dur) var(--sp-ease),
27
+ border-color var(--sp-dur) var(--sp-ease);
28
+ white-space: nowrap;
29
+ }
30
+ .sp-btn[data-size="md"] {
31
+ padding: 11px 14px;
32
+ font-size: 14px;
33
+ }
34
+ .sp-btn[data-size="sm"] {
35
+ padding: 7px 12px;
36
+ font-size: 12px;
37
+ }
38
+
39
+ .sp-btn[data-variant="primary"] {
40
+ background: var(--sp-fg);
41
+ color: var(--sp-bg);
42
+ }
43
+ .sp-btn[data-variant="primary"]:hover {
44
+ opacity: 0.9;
45
+ }
46
+
47
+ .sp-btn[data-variant="secondary"] {
48
+ background: var(--sp-bg);
49
+ color: var(--sp-fg);
50
+ border-color: var(--sp-border);
51
+ }
52
+ .sp-btn[data-variant="secondary"]:hover {
53
+ background: var(--sp-bg-muted);
54
+ }
55
+
56
+ .sp-btn[data-variant="ghost"] {
57
+ background: transparent;
58
+ color: var(--sp-fg-muted);
59
+ }
60
+ .sp-btn[data-variant="ghost"]:hover {
61
+ background: var(--sp-bg-muted);
62
+ }
63
+
64
+ .sp-btn[data-variant="danger"] {
65
+ background: var(--sp-danger);
66
+ color: #fff;
67
+ }
68
+
69
+ .sp-btn:focus-visible {
70
+ outline: 2px solid var(--sp-accent-ring);
71
+ outline-offset: 2px;
72
+ }
73
+ .sp-btn:disabled {
74
+ opacity: 0.5;
75
+ cursor: not-allowed;
76
+ }
77
+ </style>