handzon-core 0.8.4 → 0.9.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/package.json +4 -2
- package/src/collections.ts +30 -0
- package/src/components/Navbar.astro +23 -6
- package/src/components/home/Hero.astro +2 -1
- package/src/layouts/TutorialLayout.astro +14 -7
- package/src/server/mcp/startTutorial.ts +150 -0
- package/src/server/mcp/tools.ts +28 -0
- package/styles/components/diff.css +44 -8
- package/styles/components/quiz.css +22 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -65,12 +65,14 @@
|
|
|
65
65
|
"@types/react": "^19.2.15",
|
|
66
66
|
"@types/react-dom": "^19.2.3",
|
|
67
67
|
"astro": "^6.3.5",
|
|
68
|
+
"tsx": "^4.22.3",
|
|
68
69
|
"typescript": "^6.0.3"
|
|
69
70
|
},
|
|
70
71
|
"engines": {
|
|
71
72
|
"node": ">=22.0.0"
|
|
72
73
|
},
|
|
73
74
|
"scripts": {
|
|
74
|
-
"build": "echo 'handzon-core ships source — Astro type-checks at consume time'"
|
|
75
|
+
"build": "echo 'handzon-core ships source — Astro type-checks at consume time'",
|
|
76
|
+
"test": "node --import tsx --test tests/*.test.ts"
|
|
75
77
|
}
|
|
76
78
|
}
|
package/src/collections.ts
CHANGED
|
@@ -187,6 +187,35 @@ export const verifySchema = z.object({
|
|
|
187
187
|
|
|
188
188
|
export type VerifySpec = z.infer<typeof verifySchema>;
|
|
189
189
|
|
|
190
|
+
export const starterSchema = z.discriminatedUnion("kind", [
|
|
191
|
+
z
|
|
192
|
+
.object({
|
|
193
|
+
kind: z.literal("git"),
|
|
194
|
+
repo: z.string().url(),
|
|
195
|
+
ref: z.string().min(1).optional(),
|
|
196
|
+
subdir: z.string().min(1).optional(),
|
|
197
|
+
targetDir: z.string().min(1).optional(),
|
|
198
|
+
setupCommands: z.array(z.string().min(1)).default([]),
|
|
199
|
+
devCommand: z.string().min(1).optional(),
|
|
200
|
+
openPath: z.string().min(1).optional(),
|
|
201
|
+
notes: z.array(z.string().min(1)).default([]),
|
|
202
|
+
})
|
|
203
|
+
.strict(),
|
|
204
|
+
z
|
|
205
|
+
.object({
|
|
206
|
+
kind: z.literal("command"),
|
|
207
|
+
initCommand: z.string().min(1),
|
|
208
|
+
targetDir: z.string().min(1).optional(),
|
|
209
|
+
setupCommands: z.array(z.string().min(1)).default([]),
|
|
210
|
+
devCommand: z.string().min(1).optional(),
|
|
211
|
+
openPath: z.string().min(1).optional(),
|
|
212
|
+
notes: z.array(z.string().min(1)).default([]),
|
|
213
|
+
})
|
|
214
|
+
.strict(),
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
export type StarterSpec = z.infer<typeof starterSchema>;
|
|
218
|
+
|
|
190
219
|
/** Schema for tutorial step entries. */
|
|
191
220
|
export const stepsSchema = z.object({
|
|
192
221
|
title: z.string(),
|
|
@@ -226,6 +255,7 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
|
|
|
226
255
|
gated: z.boolean().default(false),
|
|
227
256
|
showProgress: z.boolean().default(true),
|
|
228
257
|
feedbackUrl: z.string().url().optional(),
|
|
258
|
+
starter: starterSchema.optional(),
|
|
229
259
|
ai: z
|
|
230
260
|
.object({
|
|
231
261
|
enabled: z.boolean().optional(),
|
|
@@ -33,17 +33,19 @@ const {
|
|
|
33
33
|
---
|
|
34
34
|
<header class="hz-nav">
|
|
35
35
|
<div class="hz-nav-inner">
|
|
36
|
-
{
|
|
37
|
-
|
|
36
|
+
<a href="/" class="hz-nav-brand" aria-label={`${siteName} home`}>
|
|
37
|
+
{logoUrl && (
|
|
38
38
|
<img
|
|
39
39
|
src={logoUrl}
|
|
40
|
-
alt=
|
|
40
|
+
alt=""
|
|
41
|
+
aria-hidden="true"
|
|
41
42
|
class="hz-nav-logo"
|
|
42
43
|
width={logoWidth}
|
|
43
44
|
height={logoHeight}
|
|
44
45
|
/>
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
)}
|
|
47
|
+
<span class="hz-nav-name">{siteName}</span>
|
|
48
|
+
</a>
|
|
47
49
|
<div class="hz-nav-actions">
|
|
48
50
|
<UserMenu />
|
|
49
51
|
</div>
|
|
@@ -77,8 +79,11 @@ const {
|
|
|
77
79
|
.hz-nav-brand {
|
|
78
80
|
display: inline-flex;
|
|
79
81
|
align-items: center;
|
|
80
|
-
|
|
82
|
+
gap: 0.45rem;
|
|
83
|
+
line-height: 1;
|
|
81
84
|
opacity: 0.92;
|
|
85
|
+
color: var(--color-fg);
|
|
86
|
+
text-decoration: none;
|
|
82
87
|
transition: opacity 0.12s ease;
|
|
83
88
|
}
|
|
84
89
|
.hz-nav-brand:hover { opacity: 1; }
|
|
@@ -86,6 +91,18 @@ const {
|
|
|
86
91
|
display: block;
|
|
87
92
|
height: 1.4rem;
|
|
88
93
|
width: auto;
|
|
94
|
+
flex-shrink: 0;
|
|
95
|
+
}
|
|
96
|
+
/* Matches the Hero headline's font choice so the wordmark reads as
|
|
97
|
+
the same brand across the homepage hero and every-other-page nav.
|
|
98
|
+
Smaller, no display tracking — this is a chrome label, not a
|
|
99
|
+
headline. */
|
|
100
|
+
.hz-nav-name {
|
|
101
|
+
font-family: var(--font-display, var(--font-sans));
|
|
102
|
+
font-weight: var(--font-weight-display, 700);
|
|
103
|
+
font-size: 1.05rem;
|
|
104
|
+
letter-spacing: var(--tracking-display, -0.02em);
|
|
105
|
+
white-space: nowrap;
|
|
89
106
|
}
|
|
90
107
|
.hz-nav-actions {
|
|
91
108
|
display: flex;
|
|
@@ -214,18 +214,25 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
|
|
|
214
214
|
height: calc(100dvh - var(--hz-nav-height, 3rem));
|
|
215
215
|
}
|
|
216
216
|
.main {
|
|
217
|
-
padding: 2rem clamp(1rem, 4vw,
|
|
218
|
-
/*
|
|
219
|
-
*
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
padding: 2rem clamp(1rem, 4vw, 4rem);
|
|
218
|
+
/* Keep tutorial pages roomy enough for code-heavy components. Prose
|
|
219
|
+
* itself is constrained below so paragraphs do not become too wide. */
|
|
220
|
+
max-width: 86ch;
|
|
221
|
+
}
|
|
222
|
+
.main > .prose {
|
|
223
|
+
max-width: 100%;
|
|
224
|
+
}
|
|
225
|
+
.main > .prose > :where(p, ul, ol, blockquote, h2, h3, h4, h5, h6) {
|
|
222
226
|
max-width: 80ch;
|
|
223
227
|
}
|
|
224
228
|
@media (min-width: 1280px) {
|
|
225
|
-
.main { max-width:
|
|
229
|
+
.main { max-width: 104ch; }
|
|
226
230
|
}
|
|
227
231
|
@media (min-width: 1600px) {
|
|
228
|
-
.main { max-width:
|
|
232
|
+
.main { max-width: 118ch; }
|
|
233
|
+
}
|
|
234
|
+
@media (min-width: 1920px) {
|
|
235
|
+
.main { max-width: 128ch; }
|
|
229
236
|
}
|
|
230
237
|
.crumb {
|
|
231
238
|
font-family: var(--font-mono);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { StarterSpec, VerifySpec } from "../../collections.ts";
|
|
2
|
+
import { type McpTool, text } from "./protocol.ts";
|
|
3
|
+
|
|
4
|
+
interface StartTutorialStep {
|
|
5
|
+
slug: string;
|
|
6
|
+
order: number;
|
|
7
|
+
title: string;
|
|
8
|
+
summary?: string;
|
|
9
|
+
duration?: string;
|
|
10
|
+
verify?: VerifySpec | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface StartTutorialInput {
|
|
14
|
+
tutorial: {
|
|
15
|
+
slug: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
difficulty: string;
|
|
19
|
+
tags: string[];
|
|
20
|
+
starter?: StarterSpec;
|
|
21
|
+
};
|
|
22
|
+
steps: StartTutorialStep[];
|
|
23
|
+
workspaceName?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type LoadStartTutorial = (
|
|
27
|
+
slug: string,
|
|
28
|
+
) => Promise<Omit<StartTutorialInput, "workspaceName"> | null>;
|
|
29
|
+
|
|
30
|
+
export interface StartTutorialPayload {
|
|
31
|
+
tutorial: {
|
|
32
|
+
slug: string;
|
|
33
|
+
title: string;
|
|
34
|
+
description: string;
|
|
35
|
+
difficulty: string;
|
|
36
|
+
tags: string[];
|
|
37
|
+
};
|
|
38
|
+
starter: StarterSpec | null;
|
|
39
|
+
workspace: {
|
|
40
|
+
targetDir: string;
|
|
41
|
+
openPath: string;
|
|
42
|
+
};
|
|
43
|
+
commands: string[];
|
|
44
|
+
firstStep: StartTutorialStep;
|
|
45
|
+
next: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pathForCommand(...parts: string[]) {
|
|
49
|
+
return parts.filter(Boolean).join("/");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveTargetDir(
|
|
53
|
+
slug: string,
|
|
54
|
+
starter: StarterSpec | undefined,
|
|
55
|
+
workspaceName: string | undefined,
|
|
56
|
+
) {
|
|
57
|
+
return workspaceName ?? starter?.targetDir ?? slug;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveOpenPath(targetDir: string, starter: StarterSpec | undefined) {
|
|
61
|
+
if (!starter) return targetDir;
|
|
62
|
+
if (starter.openPath) {
|
|
63
|
+
return starter.openPath === "." ? targetDir : starter.openPath;
|
|
64
|
+
}
|
|
65
|
+
if (starter.kind === "git" && starter.subdir) return pathForCommand(targetDir, starter.subdir);
|
|
66
|
+
return targetDir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildCommands(starter: StarterSpec | undefined, targetDir: string, openPath: string) {
|
|
70
|
+
if (!starter) return [];
|
|
71
|
+
const commands: string[] = [];
|
|
72
|
+
if (starter.kind === "git") {
|
|
73
|
+
const ref = starter.ref ? ` --branch ${starter.ref}` : "";
|
|
74
|
+
commands.push(`git clone${ref} ${starter.repo} ${targetDir}`);
|
|
75
|
+
} else {
|
|
76
|
+
commands.push(starter.initCommand);
|
|
77
|
+
}
|
|
78
|
+
commands.push(`cd ${openPath}`);
|
|
79
|
+
commands.push(...starter.setupCommands);
|
|
80
|
+
return commands;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function buildStartTutorialPayload({
|
|
84
|
+
tutorial,
|
|
85
|
+
steps,
|
|
86
|
+
workspaceName,
|
|
87
|
+
}: StartTutorialInput): StartTutorialPayload {
|
|
88
|
+
const [firstStep] = [...steps].sort((a, b) => a.order - b.order);
|
|
89
|
+
if (!firstStep) {
|
|
90
|
+
throw new Error(`Tutorial ${tutorial.slug} has no steps.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const targetDir = resolveTargetDir(tutorial.slug, tutorial.starter, workspaceName);
|
|
94
|
+
const openPath = resolveOpenPath(targetDir, tutorial.starter);
|
|
95
|
+
const commands = buildCommands(tutorial.starter, targetDir, openPath);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
tutorial: {
|
|
99
|
+
slug: tutorial.slug,
|
|
100
|
+
title: tutorial.title,
|
|
101
|
+
description: tutorial.description,
|
|
102
|
+
difficulty: tutorial.difficulty,
|
|
103
|
+
tags: tutorial.tags,
|
|
104
|
+
},
|
|
105
|
+
starter: tutorial.starter ?? null,
|
|
106
|
+
workspace: { targetDir, openPath },
|
|
107
|
+
commands,
|
|
108
|
+
firstStep,
|
|
109
|
+
next: [
|
|
110
|
+
`Call get_step with tutorial=${tutorial.slug} and step=${firstStep.slug}.`,
|
|
111
|
+
"Run the step locally in the prepared workspace.",
|
|
112
|
+
firstStep.verify
|
|
113
|
+
? "If the step has verify checks, collect observations and call submit_verification."
|
|
114
|
+
: "If the step has only a prose checkpoint, inspect the result before calling complete_checkpoint.",
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createStartTutorialTool(load: LoadStartTutorial): McpTool {
|
|
120
|
+
return {
|
|
121
|
+
name: "start_tutorial",
|
|
122
|
+
description:
|
|
123
|
+
"Return local bootstrap commands and next MCP actions for starting a tutorial from a blank workspace.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
slug: { type: "string", minLength: 1 },
|
|
128
|
+
workspaceName: {
|
|
129
|
+
type: "string",
|
|
130
|
+
minLength: 1,
|
|
131
|
+
description:
|
|
132
|
+
"Optional local directory name to use instead of the tutorial's default targetDir.",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
required: ["slug"],
|
|
136
|
+
additionalProperties: false,
|
|
137
|
+
},
|
|
138
|
+
handler: async (args) => {
|
|
139
|
+
const { slug, workspaceName } = args as { slug: string; workspaceName?: string };
|
|
140
|
+
const loaded = await load(slug);
|
|
141
|
+
if (!loaded) {
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: "text", text: `No tutorial with slug "${slug}".` }],
|
|
144
|
+
isError: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return text(JSON.stringify(buildStartTutorialPayload({ ...loaded, workspaceName }), null, 2));
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/server/mcp/tools.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { getDb } from "../db/client.ts";
|
|
10
10
|
import { progressEntries } from "../db/schema.ts";
|
|
11
11
|
import { type McpTool, text } from "./protocol.ts";
|
|
12
|
+
import { createStartTutorialTool } from "./startTutorial.ts";
|
|
12
13
|
import { progressWriteTools, verificationTools } from "./writeTools.ts";
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -72,6 +73,7 @@ export const catalogReadTools: McpTool[] = [
|
|
|
72
73
|
difficulty: tutorial.data.difficulty,
|
|
73
74
|
tags: tutorial.data.tags,
|
|
74
75
|
gated: tutorial.data.gated,
|
|
76
|
+
starter: tutorial.data.starter ?? null,
|
|
75
77
|
steps: steps.map((s) => {
|
|
76
78
|
const { stepSlug, order } = parseStepId(s.id);
|
|
77
79
|
return {
|
|
@@ -86,6 +88,32 @@ export const catalogReadTools: McpTool[] = [
|
|
|
86
88
|
return text(JSON.stringify(payload, null, 2));
|
|
87
89
|
},
|
|
88
90
|
},
|
|
91
|
+
createStartTutorialTool(async (slug) => {
|
|
92
|
+
const tutorial = await getTutorialBySlug(slug);
|
|
93
|
+
if (!tutorial) return null;
|
|
94
|
+
const steps = await getStepsForTutorial(slug);
|
|
95
|
+
return {
|
|
96
|
+
tutorial: {
|
|
97
|
+
slug: tutorial.id,
|
|
98
|
+
title: tutorial.data.title,
|
|
99
|
+
description: tutorial.data.description,
|
|
100
|
+
difficulty: tutorial.data.difficulty,
|
|
101
|
+
tags: tutorial.data.tags,
|
|
102
|
+
starter: tutorial.data.starter,
|
|
103
|
+
},
|
|
104
|
+
steps: steps.map((s) => {
|
|
105
|
+
const { stepSlug, order } = parseStepId(s.id);
|
|
106
|
+
return {
|
|
107
|
+
slug: stepSlug,
|
|
108
|
+
order,
|
|
109
|
+
title: s.data.title,
|
|
110
|
+
summary: s.data.summary,
|
|
111
|
+
duration: s.data.duration,
|
|
112
|
+
verify: s.data.verify ?? null,
|
|
113
|
+
};
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
}),
|
|
89
117
|
{
|
|
90
118
|
name: "get_step",
|
|
91
119
|
description: "Return one step's full Markdown source + metadata.",
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
margin: 1.25rem 0;
|
|
4
4
|
background: var(--color-surface);
|
|
5
5
|
font-family: var(--font-mono);
|
|
6
|
-
font-size: 0.
|
|
7
|
-
|
|
6
|
+
font-size: 0.875rem;
|
|
7
|
+
line-height: 1.55;
|
|
8
|
+
border: var(--border-default) solid var(--color-border);
|
|
9
|
+
border-radius: var(--radius-sm, 0);
|
|
8
10
|
overflow: hidden;
|
|
9
11
|
}
|
|
10
12
|
.diff-bar {
|
|
@@ -23,8 +25,16 @@
|
|
|
23
25
|
border-radius: 0;
|
|
24
26
|
}
|
|
25
27
|
.diff-bar button:hover { color: var(--color-fg); border-color: var(--color-border-strong); }
|
|
26
|
-
.diff-grid {
|
|
27
|
-
|
|
28
|
+
.diff-grid {
|
|
29
|
+
display: grid;
|
|
30
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
31
|
+
gap: 1px;
|
|
32
|
+
background: var(--color-border);
|
|
33
|
+
}
|
|
34
|
+
.diff-col {
|
|
35
|
+
min-width: 0;
|
|
36
|
+
background: var(--color-surface);
|
|
37
|
+
}
|
|
28
38
|
.diff-label {
|
|
29
39
|
padding: 0.4rem 0.6rem;
|
|
30
40
|
background: var(--color-surface-2);
|
|
@@ -35,10 +45,36 @@
|
|
|
35
45
|
}
|
|
36
46
|
.diff pre {
|
|
37
47
|
margin: 0;
|
|
38
|
-
padding: 0.
|
|
48
|
+
padding: 0.65rem 0;
|
|
39
49
|
white-space: pre;
|
|
40
50
|
overflow-x: auto;
|
|
51
|
+
tab-size: 2;
|
|
52
|
+
}
|
|
53
|
+
.diff-add,
|
|
54
|
+
.diff-del,
|
|
55
|
+
.diff-ctx {
|
|
56
|
+
display: block;
|
|
57
|
+
min-width: max-content;
|
|
58
|
+
padding: 0.08rem 0.85rem;
|
|
59
|
+
}
|
|
60
|
+
.diff-add {
|
|
61
|
+
background: color-mix(in oklab, var(--color-success) 16%, transparent);
|
|
62
|
+
color: var(--color-success);
|
|
63
|
+
}
|
|
64
|
+
.diff-del {
|
|
65
|
+
background: color-mix(in oklab, var(--color-danger) 16%, transparent);
|
|
66
|
+
color: var(--color-danger);
|
|
67
|
+
}
|
|
68
|
+
.diff-ctx {
|
|
69
|
+
color: var(--color-muted);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@media (max-width: 1100px) {
|
|
73
|
+
.diff-grid {
|
|
74
|
+
grid-template-columns: 1fr;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.diff-col + .diff-col {
|
|
78
|
+
border-top: var(--border-default) solid var(--color-border);
|
|
79
|
+
}
|
|
41
80
|
}
|
|
42
|
-
.diff-add { background: color-mix(in oklab, var(--color-success) 14%, transparent); color: var(--color-success); display: block; }
|
|
43
|
-
.diff-del { background: color-mix(in oklab, var(--color-danger) 14%, transparent); color: var(--color-danger); display: block; }
|
|
44
|
-
.diff-ctx { color: var(--color-muted); display: block; }
|
|
@@ -64,11 +64,31 @@
|
|
|
64
64
|
.quiz-opt.is-correct {
|
|
65
65
|
background: color-mix(in oklab, var(--color-success) 18%, var(--color-surface));
|
|
66
66
|
}
|
|
67
|
-
.quiz-opt.is-correct .quiz-opt-toggle {
|
|
67
|
+
.quiz-opt.is-correct .quiz-opt-toggle {
|
|
68
|
+
border-color: var(--color-success);
|
|
69
|
+
background: var(--color-success);
|
|
70
|
+
color: var(--color-bg);
|
|
71
|
+
display: inline-grid;
|
|
72
|
+
place-items: center;
|
|
73
|
+
}
|
|
68
74
|
.quiz-opt.is-wrong {
|
|
69
75
|
background: color-mix(in oklab, var(--color-danger) 16%, var(--color-surface));
|
|
70
76
|
}
|
|
71
|
-
.quiz-opt.is-wrong .quiz-opt-toggle {
|
|
77
|
+
.quiz-opt.is-wrong .quiz-opt-toggle {
|
|
78
|
+
border-color: var(--color-danger);
|
|
79
|
+
background: var(--color-danger);
|
|
80
|
+
color: var(--color-bg);
|
|
81
|
+
display: inline-grid;
|
|
82
|
+
place-items: center;
|
|
83
|
+
}
|
|
84
|
+
/* Graded options swap the accent-filled `::after` for the Lucide
|
|
85
|
+
<Check>/<X> icon injected by Quiz.tsx — without this, the purple
|
|
86
|
+
accent rectangle covers the icon and you see a purple dot inside
|
|
87
|
+
the green/red box. */
|
|
88
|
+
.quiz-opt.is-correct .quiz-opt-toggle::after,
|
|
89
|
+
.quiz-opt.is-wrong .quiz-opt-toggle::after {
|
|
90
|
+
display: none;
|
|
91
|
+
}
|
|
72
92
|
.quiz-marker {
|
|
73
93
|
display: inline-grid;
|
|
74
94
|
place-items: center;
|