srcdev-nuxt-components 9.1.39 → 9.1.41
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/.claude/settings.local.json +6 -1
- package/.claude/skills/testing-add-unit-test.md +32 -0
- package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +231 -0
- package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +235 -0
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +368 -0
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +306 -0
- package/app/composables/tests/useCancellableTimer.spec.ts +135 -0
- package/app/composables/useCancellableTimer.ts +42 -0
- package/app/pages/samaritan.vue +332 -0
- package/package.json +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { useCancellableTimer } from "../useCancellableTimer";
|
|
3
|
+
|
|
4
|
+
describe("useCancellableTimer", () => {
|
|
5
|
+
describe("wait", () => {
|
|
6
|
+
it("resolves after the specified duration", async () => {
|
|
7
|
+
const { wait, start } = useCancellableTimer();
|
|
8
|
+
start();
|
|
9
|
+
|
|
10
|
+
let resolved = false;
|
|
11
|
+
wait(200).then(() => {
|
|
12
|
+
resolved = true;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(resolved).toBe(false);
|
|
16
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
17
|
+
expect(resolved).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not resolve before the duration elapses", async () => {
|
|
21
|
+
const { wait, start } = useCancellableTimer();
|
|
22
|
+
start();
|
|
23
|
+
|
|
24
|
+
let resolved = false;
|
|
25
|
+
wait(200).then(() => {
|
|
26
|
+
resolved = true;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await vi.advanceTimersByTimeAsync(199);
|
|
30
|
+
expect(resolved).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects immediately when not started", async () => {
|
|
34
|
+
const { wait } = useCancellableTimer();
|
|
35
|
+
|
|
36
|
+
let rejected = false;
|
|
37
|
+
wait(200).catch(() => {
|
|
38
|
+
rejected = true;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await Promise.resolve();
|
|
42
|
+
expect(rejected).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects when stop is called before the timer fires", async () => {
|
|
46
|
+
const { wait, start, stop } = useCancellableTimer();
|
|
47
|
+
start();
|
|
48
|
+
|
|
49
|
+
let rejected = false;
|
|
50
|
+
wait(200).catch(() => {
|
|
51
|
+
rejected = true;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
stop();
|
|
55
|
+
await Promise.resolve();
|
|
56
|
+
expect(rejected).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("schedule", () => {
|
|
61
|
+
it("calls fn after the specified delay", async () => {
|
|
62
|
+
const { schedule, start } = useCancellableTimer();
|
|
63
|
+
start();
|
|
64
|
+
|
|
65
|
+
const fn = vi.fn();
|
|
66
|
+
schedule(fn, 100);
|
|
67
|
+
|
|
68
|
+
expect(fn).not.toHaveBeenCalled();
|
|
69
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
70
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not call fn when not started", async () => {
|
|
74
|
+
const { schedule } = useCancellableTimer();
|
|
75
|
+
|
|
76
|
+
const fn = vi.fn();
|
|
77
|
+
schedule(fn, 100);
|
|
78
|
+
|
|
79
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
80
|
+
expect(fn).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not call fn when stop is called before the delay fires", async () => {
|
|
84
|
+
const { schedule, start, stop } = useCancellableTimer();
|
|
85
|
+
start();
|
|
86
|
+
|
|
87
|
+
const fn = vi.fn();
|
|
88
|
+
schedule(fn, 100);
|
|
89
|
+
|
|
90
|
+
stop();
|
|
91
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
92
|
+
expect(fn).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("stop", () => {
|
|
97
|
+
it("cancels a pending wait", async () => {
|
|
98
|
+
const { wait, start, stop } = useCancellableTimer();
|
|
99
|
+
start();
|
|
100
|
+
|
|
101
|
+
let settled = false;
|
|
102
|
+
wait(100).then(
|
|
103
|
+
() => { settled = true; },
|
|
104
|
+
() => { settled = true; },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
stop();
|
|
108
|
+
await Promise.resolve();
|
|
109
|
+
expect(settled).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("start / stop cycle", () => {
|
|
114
|
+
it("can restart after stop", async () => {
|
|
115
|
+
const { wait, start, stop } = useCancellableTimer();
|
|
116
|
+
start();
|
|
117
|
+
|
|
118
|
+
let firstRejected = false;
|
|
119
|
+
wait(100).catch(() => {
|
|
120
|
+
firstRejected = true;
|
|
121
|
+
});
|
|
122
|
+
stop();
|
|
123
|
+
await Promise.resolve();
|
|
124
|
+
expect(firstRejected).toBe(true);
|
|
125
|
+
|
|
126
|
+
start();
|
|
127
|
+
let secondResolved = false;
|
|
128
|
+
wait(100).then(() => {
|
|
129
|
+
secondResolved = true;
|
|
130
|
+
});
|
|
131
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
132
|
+
expect(secondResolved).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function useCancellableTimer() {
|
|
2
|
+
let _isActive = false;
|
|
3
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
4
|
+
let rejectCurrent: (() => void) | null = null;
|
|
5
|
+
|
|
6
|
+
const wait = (ms: number): Promise<void> =>
|
|
7
|
+
new Promise((resolve, reject) => {
|
|
8
|
+
if (!_isActive) {
|
|
9
|
+
reject();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
rejectCurrent = reject;
|
|
13
|
+
timeoutId = setTimeout(() => {
|
|
14
|
+
rejectCurrent = null;
|
|
15
|
+
if (_isActive) resolve();
|
|
16
|
+
else reject();
|
|
17
|
+
}, ms);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const schedule = (fn: () => void, ms: number) => {
|
|
21
|
+
if (!_isActive) return;
|
|
22
|
+
timeoutId = setTimeout(() => {
|
|
23
|
+
if (_isActive) fn();
|
|
24
|
+
}, ms);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const stop = () => {
|
|
28
|
+
_isActive = false;
|
|
29
|
+
if (timeoutId !== null) {
|
|
30
|
+
clearTimeout(timeoutId);
|
|
31
|
+
timeoutId = null;
|
|
32
|
+
}
|
|
33
|
+
rejectCurrent?.();
|
|
34
|
+
rejectCurrent = null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const start = () => {
|
|
38
|
+
_isActive = true;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return { wait, schedule, stop, start };
|
|
42
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<NuxtLayout name="default">
|
|
4
|
+
<template #layout-content>
|
|
5
|
+
<div v-if="isDev" class="qa-panel">
|
|
6
|
+
<details class="qa-panel__details">
|
|
7
|
+
<summary class="qa-panel__summary">
|
|
8
|
+
<span class="qa-panel__title">QA — SamaritanPrompt</span>
|
|
9
|
+
<code v-if="qaEffect === 'typewriter'" class="qa-panel__status">
|
|
10
|
+
effect:typewriter · type:{{ qaTypeSpeed }}ms · hold:{{ qaHoldDuration }}ms · del:{{ qaDeleteSpeed }}ms ·
|
|
11
|
+
pause:{{ qaPauseDuration }}ms
|
|
12
|
+
</code>
|
|
13
|
+
<code v-else class="qa-panel__status">
|
|
14
|
+
effect:word-pulse · fade:{{ qaFadeDuration }}ms · visible:{{ qaWordDuration }}ms · pause:{{
|
|
15
|
+
qaPauseDuration
|
|
16
|
+
}}ms
|
|
17
|
+
</code>
|
|
18
|
+
</summary>
|
|
19
|
+
<div class="qa-panel__body">
|
|
20
|
+
<div class="qa-panel__group">
|
|
21
|
+
<span class="qa-panel__label">Effect</span>
|
|
22
|
+
<div class="qa-panel__chips">
|
|
23
|
+
<button
|
|
24
|
+
v-for="opt in effectOptions"
|
|
25
|
+
:key="opt"
|
|
26
|
+
class="qa-panel__chip"
|
|
27
|
+
:class="{ 'is-active': qaEffect === opt }"
|
|
28
|
+
@click="qaEffect = opt"
|
|
29
|
+
>
|
|
30
|
+
{{ opt }}
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Typewriter controls -->
|
|
36
|
+
<template v-if="qaEffect === 'typewriter'">
|
|
37
|
+
<div class="qa-panel__group">
|
|
38
|
+
<span class="qa-panel__label">Type speed (ms/char)</span>
|
|
39
|
+
<div class="qa-panel__chips">
|
|
40
|
+
<button
|
|
41
|
+
v-for="n in [40, 80, 120, 200]"
|
|
42
|
+
:key="n"
|
|
43
|
+
class="qa-panel__chip"
|
|
44
|
+
:class="{ 'is-active': qaTypeSpeed === n }"
|
|
45
|
+
@click="qaTypeSpeed = n"
|
|
46
|
+
>
|
|
47
|
+
{{ n }}
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="qa-panel__group">
|
|
53
|
+
<span class="qa-panel__label">Hold duration (ms)</span>
|
|
54
|
+
<div class="qa-panel__chips">
|
|
55
|
+
<button
|
|
56
|
+
v-for="n in [500, 1000, 2000, 4000]"
|
|
57
|
+
:key="n"
|
|
58
|
+
class="qa-panel__chip"
|
|
59
|
+
:class="{ 'is-active': qaHoldDuration === n }"
|
|
60
|
+
@click="qaHoldDuration = n"
|
|
61
|
+
>
|
|
62
|
+
{{ n }}
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="qa-panel__group">
|
|
68
|
+
<span class="qa-panel__label">Delete speed (ms/char)</span>
|
|
69
|
+
<div class="qa-panel__chips">
|
|
70
|
+
<button
|
|
71
|
+
v-for="n in [20, 40, 80]"
|
|
72
|
+
:key="n"
|
|
73
|
+
class="qa-panel__chip"
|
|
74
|
+
:class="{ 'is-active': qaDeleteSpeed === n }"
|
|
75
|
+
@click="qaDeleteSpeed = n"
|
|
76
|
+
>
|
|
77
|
+
{{ n }}
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</template>
|
|
82
|
+
|
|
83
|
+
<!-- Word-pulse controls -->
|
|
84
|
+
<template v-else>
|
|
85
|
+
<div class="qa-panel__group">
|
|
86
|
+
<span class="qa-panel__label">Fade duration (ms)</span>
|
|
87
|
+
<div class="qa-panel__chips">
|
|
88
|
+
<button
|
|
89
|
+
v-for="n in [200, 400, 600, 1000]"
|
|
90
|
+
:key="n"
|
|
91
|
+
class="qa-panel__chip"
|
|
92
|
+
:class="{ 'is-active': qaFadeDuration === n }"
|
|
93
|
+
@click="qaFadeDuration = n"
|
|
94
|
+
>
|
|
95
|
+
{{ n }}
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div class="qa-panel__group">
|
|
101
|
+
<span class="qa-panel__label">Visible duration (ms)</span>
|
|
102
|
+
<div class="qa-panel__chips">
|
|
103
|
+
<button
|
|
104
|
+
v-for="n in [800, 1200, 2000, 3000]"
|
|
105
|
+
:key="n"
|
|
106
|
+
class="qa-panel__chip"
|
|
107
|
+
:class="{ 'is-active': qaWordDuration === n }"
|
|
108
|
+
@click="qaWordDuration = n"
|
|
109
|
+
>
|
|
110
|
+
{{ n }}
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</template>
|
|
115
|
+
|
|
116
|
+
<!-- Shared: pause before restart -->
|
|
117
|
+
<div class="qa-panel__group">
|
|
118
|
+
<span class="qa-panel__label">Pause before restart (ms)</span>
|
|
119
|
+
<div class="qa-panel__chips">
|
|
120
|
+
<button
|
|
121
|
+
v-for="n in [300, 500, 1000, 2000, 5000, 10000]"
|
|
122
|
+
:key="n"
|
|
123
|
+
class="qa-panel__chip"
|
|
124
|
+
:class="{ 'is-active': qaPauseDuration === n }"
|
|
125
|
+
@click="qaPauseDuration = n"
|
|
126
|
+
>
|
|
127
|
+
{{ n }}
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Shared: hide cursor in cycle -->
|
|
133
|
+
<div class="qa-panel__group">
|
|
134
|
+
<span class="qa-panel__label">Hide cursor in cycle</span>
|
|
135
|
+
<div class="qa-panel__chips">
|
|
136
|
+
<button
|
|
137
|
+
v-for="opt in [true, false]"
|
|
138
|
+
:key="String(opt)"
|
|
139
|
+
class="qa-panel__chip"
|
|
140
|
+
:class="{ 'is-active': qaHideCursorInCycle === opt }"
|
|
141
|
+
@click="qaHideCursorInCycle = opt"
|
|
142
|
+
>
|
|
143
|
+
{{ opt ? "on" : "off" }}
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</details>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<LayoutRow tag="div" variant="full-width">
|
|
152
|
+
<div class="samaritan-stage samaritan-stage--mixed">
|
|
153
|
+
<SamaritanPromptMixed :message-configs="mixedMessages" :hide-cursor-in-cycle="false" />
|
|
154
|
+
</div>
|
|
155
|
+
</LayoutRow>
|
|
156
|
+
|
|
157
|
+
<LayoutRow tag="div" variant="full-width">
|
|
158
|
+
<div class="samaritan-stage">
|
|
159
|
+
<SamaritanPrompt
|
|
160
|
+
:messages="messages"
|
|
161
|
+
:effect="qaEffect"
|
|
162
|
+
:type-speed="qaTypeSpeed"
|
|
163
|
+
:delete-speed="qaDeleteSpeed"
|
|
164
|
+
:hold-duration="qaHoldDuration"
|
|
165
|
+
:pause-duration="qaPauseDuration"
|
|
166
|
+
:word-duration="qaWordDuration"
|
|
167
|
+
:fade-duration="qaFadeDuration"
|
|
168
|
+
:hide-cursor-in-cycle="qaHideCursorInCycle"
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
</LayoutRow>
|
|
172
|
+
</template>
|
|
173
|
+
</NuxtLayout>
|
|
174
|
+
</div>
|
|
175
|
+
</template>
|
|
176
|
+
|
|
177
|
+
<script setup lang="ts">
|
|
178
|
+
definePageMeta({
|
|
179
|
+
layout: false,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
useHead({
|
|
183
|
+
title: "Samaritan",
|
|
184
|
+
meta: [{ name: "description", content: "Samaritan prompt effect" }],
|
|
185
|
+
bodyAttrs: { class: "samaritan-page" },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const messages = [
|
|
189
|
+
"What do I do?",
|
|
190
|
+
"Bring your imaginaton to life",
|
|
191
|
+
"Craft beautiful interfaces",
|
|
192
|
+
"Keep it simple",
|
|
193
|
+
"My work is?",
|
|
194
|
+
"Performance tested",
|
|
195
|
+
"Penetration tested",
|
|
196
|
+
"Accessible",
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const mixedMessages = [
|
|
200
|
+
{ text: "What do I do?", effect: "typewriter" as const, holdDuration: 7000, pauseDuration: 3000 },
|
|
201
|
+
{ text: "Bring your imaginaton to life", effect: "word-pulse" as const, wordDuration: 7000, pauseDuration: 3000 },
|
|
202
|
+
{ text: "Craft beautiful interfaces", effect: "word-pulse" as const, wordDuration: 7000, pauseDuration: 3000 },
|
|
203
|
+
{ text: "Keep it simple", effect: "word-pulse" as const, wordDuration: 7000, pauseDuration: 3000 },
|
|
204
|
+
{ text: "My work is?", effect: "typewriter" as const, holdDuration: 7000, pauseDuration: 3000 },
|
|
205
|
+
{ text: "Performance tested", effect: "word-pulse" as const, wordDuration: 7000, pauseDuration: 3000 },
|
|
206
|
+
{ text: "Penetration tested", effect: "word-pulse" as const, wordDuration: 7000, pauseDuration: 3000 },
|
|
207
|
+
{ text: "Accessible", effect: "word-pulse" as const, wordDuration: 7000, pauseDuration: 3000 },
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
// ── QA controls (dev only) ────────────────────────────────────────
|
|
211
|
+
const isDev = import.meta.dev;
|
|
212
|
+
|
|
213
|
+
const effectOptions = ["typewriter", "word-pulse"] as const;
|
|
214
|
+
const qaEffect = ref<"typewriter" | "word-pulse">("typewriter");
|
|
215
|
+
|
|
216
|
+
const qaTypeSpeed = ref(80);
|
|
217
|
+
const qaDeleteSpeed = ref(40);
|
|
218
|
+
const qaHoldDuration = ref(2000);
|
|
219
|
+
const qaPauseDuration = ref(5000);
|
|
220
|
+
const qaWordDuration = ref(1200);
|
|
221
|
+
const qaFadeDuration = ref(400);
|
|
222
|
+
const qaHideCursorInCycle = ref(true);
|
|
223
|
+
</script>
|
|
224
|
+
|
|
225
|
+
<style lang="css">
|
|
226
|
+
.samaritan-page {
|
|
227
|
+
--samaritan-color-text: light-dark(black, white);
|
|
228
|
+
--samaritan-color-underline: light-dark(black, white);
|
|
229
|
+
--samaritan-font-size: clamp(1.4rem, 2.5vw, 2.8rem);
|
|
230
|
+
--samaritan-letter-spacing: 0.14em;
|
|
231
|
+
|
|
232
|
+
.samaritan-stage {
|
|
233
|
+
display: flex;
|
|
234
|
+
align-items: center;
|
|
235
|
+
justify-content: center;
|
|
236
|
+
min-block-size: 100dvh;
|
|
237
|
+
|
|
238
|
+
.samaritan-stage--mixed {
|
|
239
|
+
border-top: 1px solid oklch(100% 0 0 / 0.08);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ── QA Panel ──────────────────────────────────────────────────── */
|
|
244
|
+
|
|
245
|
+
.qa-panel {
|
|
246
|
+
background: oklch(15% 0 0);
|
|
247
|
+
color: white;
|
|
248
|
+
font-size: 1.3rem;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.qa-panel__details {
|
|
252
|
+
padding: 1rem 2rem;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.qa-panel__summary {
|
|
256
|
+
cursor: pointer;
|
|
257
|
+
display: flex;
|
|
258
|
+
align-items: center;
|
|
259
|
+
gap: 1.6rem;
|
|
260
|
+
list-style: none;
|
|
261
|
+
user-select: none;
|
|
262
|
+
|
|
263
|
+
&::-webkit-details-marker {
|
|
264
|
+
display: none;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.qa-panel__title {
|
|
269
|
+
font-weight: 600;
|
|
270
|
+
font-size: 1.1rem;
|
|
271
|
+
text-transform: uppercase;
|
|
272
|
+
letter-spacing: 0.08em;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.qa-panel__status {
|
|
276
|
+
font-family: monospace;
|
|
277
|
+
font-size: 1.2rem;
|
|
278
|
+
background: oklch(0% 0 0 / 0.3);
|
|
279
|
+
padding: 0.2rem 0.8rem;
|
|
280
|
+
border-radius: 0.4rem;
|
|
281
|
+
user-select: text;
|
|
282
|
+
cursor: text;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.qa-panel__body {
|
|
286
|
+
display: flex;
|
|
287
|
+
flex-wrap: wrap;
|
|
288
|
+
gap: 2.4rem;
|
|
289
|
+
padding-block: 1.2rem 0.4rem;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.qa-panel__group {
|
|
293
|
+
display: flex;
|
|
294
|
+
flex-direction: column;
|
|
295
|
+
gap: 0.6rem;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.qa-panel__label {
|
|
299
|
+
font-size: 1.1rem;
|
|
300
|
+
text-transform: uppercase;
|
|
301
|
+
letter-spacing: 0.08em;
|
|
302
|
+
opacity: 0.55;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.qa-panel__chips {
|
|
306
|
+
display: flex;
|
|
307
|
+
flex-wrap: wrap;
|
|
308
|
+
gap: 0.4rem;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.qa-panel__chip {
|
|
312
|
+
font-family: monospace;
|
|
313
|
+
font-size: 1.2rem;
|
|
314
|
+
color: white;
|
|
315
|
+
background: oklch(0% 0 0 / 0.25);
|
|
316
|
+
border: 1px solid oklch(100% 0 0 / 0.18);
|
|
317
|
+
padding: 0.3rem 1rem;
|
|
318
|
+
border-radius: 0.4rem;
|
|
319
|
+
cursor: pointer;
|
|
320
|
+
transition: background 0.15s;
|
|
321
|
+
|
|
322
|
+
&:hover {
|
|
323
|
+
background: oklch(0% 0 0 / 0.4);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
&.is-active {
|
|
327
|
+
background: oklch(55% 0.18 240);
|
|
328
|
+
border-color: oklch(55% 0.18 240);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
</style>
|