Initial commit
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { micromark } from "micromark";
|
||||
import { LabContent } from "~/components/labs/LabContent";
|
||||
import { getLabDocument, getLabSummaries } from "~/lib/labs";
|
||||
type ObjectiveStyle = "divider" | "cards" | "rail";
|
||||
const objectiveStyles = new Set<ObjectiveStyle>(["divider", "cards", "rail"]);
|
||||
type StepStyle = "underline" | "pills" | "blocks";
|
||||
const stepStyles = new Set<StepStyle>(["underline", "pills", "blocks"]);
|
||||
type BreakoutStyle = "none" | "panel" | "workflow" | "command-pills" | "instruction-rails";
|
||||
const breakoutStyles = new Set<BreakoutStyle>([
|
||||
"none",
|
||||
"panel",
|
||||
"workflow",
|
||||
"command-pills",
|
||||
"instruction-rails",
|
||||
]);
|
||||
|
||||
function normalizeObjectiveStyle(style: unknown): ObjectiveStyle | null {
|
||||
if (typeof style !== "string") return null;
|
||||
const normalized = style.trim().toLowerCase() as ObjectiveStyle;
|
||||
return objectiveStyles.has(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeStepStyle(style: unknown): StepStyle | null {
|
||||
if (typeof style !== "string") return null;
|
||||
const normalized = style.trim().toLowerCase() as StepStyle;
|
||||
return stepStyles.has(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeBreakoutStyle(style: unknown): BreakoutStyle | null {
|
||||
if (typeof style !== "string") return null;
|
||||
const normalized = style.trim().toLowerCase() as BreakoutStyle;
|
||||
return breakoutStyles.has(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function extractObjectiveStyleDirective(markdown: string) {
|
||||
const styleDirectivePattern = /<!--\s*objective-style:\s*([a-z-]+)\s*-->/i;
|
||||
const match = styleDirectivePattern.exec(markdown);
|
||||
const style = normalizeObjectiveStyle(match?.[1]);
|
||||
|
||||
if (!match) {
|
||||
return { markdown, style };
|
||||
}
|
||||
|
||||
const cleanedMarkdown = markdown.replace(styleDirectivePattern, "").trimStart();
|
||||
return { markdown: cleanedMarkdown, style };
|
||||
}
|
||||
|
||||
function extractBreakoutStyleDirective(markdown: string) {
|
||||
const styleDirectivePattern = /<!--\s*breakout-style:\s*([a-z-]+)\s*-->/i;
|
||||
const match = styleDirectivePattern.exec(markdown);
|
||||
const style = normalizeBreakoutStyle(match?.[1]);
|
||||
|
||||
if (!match) {
|
||||
return { markdown, style };
|
||||
}
|
||||
|
||||
const cleanedMarkdown = markdown.replace(styleDirectivePattern, "").trimStart();
|
||||
return { markdown: cleanedMarkdown, style };
|
||||
}
|
||||
|
||||
function extractStepStyleDirective(markdown: string) {
|
||||
const styleDirectivePattern = /<!--\s*step-style:\s*([a-z-]+)\s*-->/i;
|
||||
const match = styleDirectivePattern.exec(markdown);
|
||||
const style = normalizeStepStyle(match?.[1]);
|
||||
|
||||
if (!match) {
|
||||
return { markdown, style };
|
||||
}
|
||||
|
||||
const cleanedMarkdown = markdown.replace(styleDirectivePattern, "").trimStart();
|
||||
return { markdown: cleanedMarkdown, style };
|
||||
}
|
||||
|
||||
function extractPlainText(htmlText: string) {
|
||||
return htmlText
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isObjectiveHeading(headingHtml: string) {
|
||||
const plainText = extractPlainText(headingHtml);
|
||||
return /^objective\b/i.test(plainText);
|
||||
}
|
||||
|
||||
function stripObjectiveDividers(html: string) {
|
||||
return html.replace(
|
||||
/<hr\s*\/?>\s*(?=<h2(?:\s+[^>]*)?>\s*Objective\b)/gi,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
function transformOutsideDetails(
|
||||
html: string,
|
||||
transform: (safeHtml: string) => string,
|
||||
) {
|
||||
const detailsPattern = /<details(?:\s+[^>]*)?>[\s\S]*?<\/details>/gi;
|
||||
const detailsBlocks: string[] = [];
|
||||
|
||||
const maskedHtml = html.replace(detailsPattern, (detailsBlock) => {
|
||||
const token = `__DETAILS_BLOCK_${detailsBlocks.length}__`;
|
||||
detailsBlocks.push(detailsBlock);
|
||||
return token;
|
||||
});
|
||||
|
||||
const transformedHtml = transform(maskedHtml);
|
||||
return transformedHtml.replace(/__DETAILS_BLOCK_(\d+)__/g, (_, indexText: string) => {
|
||||
const index = Number(indexText);
|
||||
return detailsBlocks[index] ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
function segmentObjectiveSections(html: string) {
|
||||
const headingPattern = /<h2(?:\s+[^>]*)?>([\s\S]*?)<\/h2>/gi;
|
||||
const headings: Array<{ index: number; text: string }> = [];
|
||||
let match = headingPattern.exec(html);
|
||||
|
||||
while (match) {
|
||||
headings.push({ index: match.index, text: match[1] ?? "" });
|
||||
match = headingPattern.exec(html);
|
||||
}
|
||||
|
||||
if (headings.length === 0) return html;
|
||||
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const heading = headings[i];
|
||||
if (!isObjectiveHeading(heading.text)) continue;
|
||||
|
||||
const nextObjectiveHeading = headings.slice(i + 1).find((nextHeading) => {
|
||||
return isObjectiveHeading(nextHeading.text);
|
||||
});
|
||||
const nextHeadingIndex = nextObjectiveHeading?.index ?? html.length;
|
||||
output += html.slice(cursor, heading.index);
|
||||
output += '<section class="objective-segment">';
|
||||
output += html.slice(heading.index, nextHeadingIndex);
|
||||
output += "</section>";
|
||||
cursor = nextHeadingIndex;
|
||||
}
|
||||
|
||||
output += html.slice(cursor);
|
||||
return output;
|
||||
}
|
||||
|
||||
function isStepHeading(headingHtml: string) {
|
||||
const plainText = extractPlainText(headingHtml);
|
||||
if (!plainText) return false;
|
||||
|
||||
return (
|
||||
/^step\b/i.test(plainText) ||
|
||||
/^\d+(?:\.\d+)*(?:[.):])?\s+/.test(plainText) ||
|
||||
/\b(explore|execute|checkpoint|review)\b/i.test(plainText)
|
||||
);
|
||||
}
|
||||
|
||||
function getStepMode(headingHtml: string) {
|
||||
const plainText = extractPlainText(headingHtml).toLowerCase();
|
||||
if (plainText.includes("execute")) return "execute";
|
||||
if (plainText.includes("explore")) return "explore";
|
||||
if (plainText.includes("checkpoint")) return "checkpoint";
|
||||
if (plainText.includes("review")) return "review";
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeCommandBlock(codeHtml: string) {
|
||||
const codeText = codeHtml
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"');
|
||||
|
||||
return (
|
||||
/(^|\n)\s*(\$|sudo\s|git\s|python3?\s|pip\s|npm\s|pnpm\s|yarn\s|llama-|ollama\s|curl\s|wget\s|apt\s|cd\s|ls\s|cat\s|cp\s|mv\s|chmod\s|make\s)/i.test(
|
||||
codeText,
|
||||
) || /--[a-z0-9-]+/i.test(codeText)
|
||||
);
|
||||
}
|
||||
|
||||
function commandLinesToHtml(codeHtml: string) {
|
||||
const lines = codeHtml
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return `<span class="cmd-pill">${codeHtml}</span>`;
|
||||
}
|
||||
|
||||
return lines.map((line) => `<span class="cmd-pill">${line}</span>`).join("\n");
|
||||
}
|
||||
|
||||
function markExplicitInstructionElements(
|
||||
html: string,
|
||||
options?: {
|
||||
commandPills?: boolean;
|
||||
},
|
||||
) {
|
||||
const renderCommandPills = options?.commandPills ?? false;
|
||||
const imperativeLead =
|
||||
/^(?:\d+\.\s*)?(?:open|go to|navigate to|click|select|run|enter|type|copy|paste|create|clone|convert|inspect|execute|use|download|install|review|confirm|kill|rerun|quit|start|stop)\b/i;
|
||||
|
||||
let markedHtml = html.replace(
|
||||
/<pre([^>]*)>\s*<code([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/gi,
|
||||
(fullMatch, rawPreAttrs: string, rawCodeAttrs: string, codeHtml: string) => {
|
||||
if (!looksLikeCommandBlock(codeHtml)) return fullMatch;
|
||||
|
||||
const preAttrs = addClassAttribute(rawPreAttrs.trim(), "explicit-command-block");
|
||||
const codeAttrs = addClassAttribute(rawCodeAttrs.trim(), "explicit-command");
|
||||
const commandContent = renderCommandPills ? commandLinesToHtml(codeHtml) : codeHtml;
|
||||
|
||||
return `<pre${preAttrs ? ` ${preAttrs}` : ""}><code${codeAttrs ? ` ${codeAttrs}` : ""}>${commandContent}</code></pre>`;
|
||||
},
|
||||
);
|
||||
|
||||
markedHtml = markedHtml.replace(
|
||||
/<(p|li)([^>]*)>([\s\S]*?)<\/\1>/gi,
|
||||
(fullMatch, tagName: string, rawAttrs: string, innerHtml: string) => {
|
||||
const plainText = extractPlainText(innerHtml);
|
||||
if (!imperativeLead.test(plainText)) return fullMatch;
|
||||
|
||||
const attrs = addClassAttribute(rawAttrs.trim(), "explicit-instruction");
|
||||
return `<${tagName}${attrs ? ` ${attrs}` : ""}>${innerHtml}</${tagName}>`;
|
||||
},
|
||||
);
|
||||
|
||||
return markedHtml;
|
||||
}
|
||||
|
||||
function classifyStepKind(sectionHtml: string, mode: string | null) {
|
||||
const hasExplicitInstruction = /class="[^"]*\bexplicit-instruction\b[^"]*"/i.test(sectionHtml);
|
||||
const hasCommandBlock = /class="[^"]*\bexplicit-command-block\b[^"]*"/i.test(sectionHtml);
|
||||
const hasOrderedList = /<ol>/i.test(sectionHtml);
|
||||
|
||||
if (mode === "execute" || mode === "checkpoint") return "instruction";
|
||||
if (mode === "explore" || mode === "review") {
|
||||
return hasCommandBlock ? "instruction" : "explanation";
|
||||
}
|
||||
|
||||
if (hasCommandBlock || hasExplicitInstruction) return "instruction";
|
||||
if (hasOrderedList) return "mixed";
|
||||
return "explanation";
|
||||
}
|
||||
|
||||
function findObjectiveSegmentOpenStart(html: string, headingIndex: number) {
|
||||
const lookbackStart = Math.max(0, headingIndex - 120);
|
||||
const beforeHeading = html.slice(lookbackStart, headingIndex);
|
||||
const openTagMatch = /<section class="objective-segment">\s*$/i.exec(beforeHeading);
|
||||
|
||||
if (!openTagMatch) return headingIndex;
|
||||
return headingIndex - openTagMatch[0].length;
|
||||
}
|
||||
|
||||
function segmentStepSections(html: string) {
|
||||
const headingPattern = /<h([2-4])([^>]*)>([\s\S]*?)<\/h\1>/gi;
|
||||
const headings: Array<{
|
||||
index: number;
|
||||
level: number;
|
||||
attrs: string;
|
||||
text: string;
|
||||
isStep: boolean;
|
||||
}> = [];
|
||||
let match = headingPattern.exec(html);
|
||||
|
||||
while (match) {
|
||||
const level = Number(match[1] ?? "0");
|
||||
const attrs = match[2] ?? "";
|
||||
const text = match[3] ?? "";
|
||||
const hasStepClass = /\bclass\s*=\s*"[^"]*\blab-step-title\b/i.test(attrs);
|
||||
const isObjectiveLevelTwo = level === 2 && isObjectiveHeading(text);
|
||||
const supportsStepSegmentation = level >= 3 || (level === 2 && !isObjectiveLevelTwo);
|
||||
headings.push({
|
||||
index: match.index,
|
||||
level,
|
||||
attrs,
|
||||
text,
|
||||
isStep: supportsStepSegmentation && (hasStepClass || isStepHeading(text)),
|
||||
});
|
||||
match = headingPattern.exec(html);
|
||||
}
|
||||
|
||||
if (headings.length === 0) return html;
|
||||
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const heading = headings[i];
|
||||
if (!heading.isStep) continue;
|
||||
|
||||
let nextIndex = html.length;
|
||||
for (let j = i + 1; j < headings.length; j++) {
|
||||
const nextHeading = headings[j];
|
||||
if (nextHeading.level === 2 || nextHeading.isStep) {
|
||||
nextIndex = findObjectiveSegmentOpenStart(html, nextHeading.index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sectionHtml = html.slice(heading.index, nextIndex);
|
||||
const modeFromAttrs = /data-step-mode\s*=\s*"([^"]+)"/i.exec(heading.attrs)?.[1] ?? null;
|
||||
const mode = modeFromAttrs ?? getStepMode(heading.text);
|
||||
const kind = classifyStepKind(sectionHtml, mode);
|
||||
const modeAttribute = mode ? ` data-step-mode="${mode}"` : "";
|
||||
|
||||
output += html.slice(cursor, heading.index);
|
||||
output += `<section class="step-segment" data-step-kind="${kind}"${modeAttribute}>${sectionHtml}</section>`;
|
||||
cursor = nextIndex;
|
||||
}
|
||||
|
||||
output += html.slice(cursor);
|
||||
return output;
|
||||
}
|
||||
|
||||
function addClassAttribute(attrs: string, className: string) {
|
||||
const classPattern = /class\s*=\s*"([^"]*)"/i;
|
||||
if (classPattern.test(attrs)) {
|
||||
return attrs.replace(classPattern, (_, classList: string) => {
|
||||
const classes = classList.split(/\s+/).filter(Boolean);
|
||||
if (!classes.includes(className)) classes.push(className);
|
||||
return `class="${classes.join(" ")}"`;
|
||||
});
|
||||
}
|
||||
return `${attrs} class="${className}"`.trim();
|
||||
}
|
||||
|
||||
function addDataAttribute(attrs: string, name: string, value: string) {
|
||||
const attrPattern = new RegExp(`\\b${name}\\s*=`, "i");
|
||||
if (attrPattern.test(attrs)) return attrs;
|
||||
return `${attrs} ${name}="${value}"`.trim();
|
||||
}
|
||||
|
||||
function stripStepOrdinalPrefix(headingHtml: string) {
|
||||
return headingHtml.replace(/^(\s*)(?:\d+(?:\.\d+)*(?:[.):])?\s+)/, "$1");
|
||||
}
|
||||
|
||||
function annotateStepHeadings(
|
||||
html: string,
|
||||
options?: {
|
||||
stripOrdinals?: boolean;
|
||||
},
|
||||
) {
|
||||
const stripOrdinals = options?.stripOrdinals ?? false;
|
||||
|
||||
return html.replace(
|
||||
/<h([2-4])([^>]*)>([\s\S]*?)<\/h\1>/gi,
|
||||
(fullMatch, level: string, rawAttrs: string, headingHtml: string) => {
|
||||
const numericLevel = Number(level);
|
||||
if (numericLevel === 2 && isObjectiveHeading(headingHtml)) return fullMatch;
|
||||
if (!isStepHeading(headingHtml)) return fullMatch;
|
||||
|
||||
let attrs = rawAttrs.trim();
|
||||
attrs = addClassAttribute(attrs, "lab-step-title");
|
||||
|
||||
const mode = getStepMode(headingHtml);
|
||||
if (mode) {
|
||||
attrs = addDataAttribute(attrs, "data-step-mode", mode);
|
||||
}
|
||||
|
||||
const displayHeadingHtml = stripOrdinals
|
||||
? stripStepOrdinalPrefix(headingHtml)
|
||||
: headingHtml;
|
||||
|
||||
return `<h${level}${attrs ? ` ${attrs}` : ""}>${displayHeadingHtml}</h${level}>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function splitTableRow(line: string) {
|
||||
let row = line.trim();
|
||||
if (row.startsWith("|")) row = row.slice(1);
|
||||
if (row.endsWith("|")) row = row.slice(0, -1);
|
||||
return row.split("|").map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function isTableDivider(line: string) {
|
||||
const cells = splitTableRow(line);
|
||||
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
||||
}
|
||||
|
||||
function isTableRow(line: string) {
|
||||
if (!line.includes("|")) return false;
|
||||
return splitTableRow(line).length > 1;
|
||||
}
|
||||
|
||||
function tableAlignment(dividerCell: string) {
|
||||
if (/^:-+:$/.test(dividerCell)) return ' style="text-align:center;"';
|
||||
if (/^-+:$/.test(dividerCell)) return ' style="text-align:right;"';
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(markdown: string) {
|
||||
const html = micromark(markdown, { allowDangerousHtml: false }).trim();
|
||||
if (html.startsWith("<p>") && html.endsWith("</p>")) {
|
||||
return html.slice(3, -4);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function convertGfmTables(markdown: string) {
|
||||
const lines = markdown.split("\n");
|
||||
const out: string[] = [];
|
||||
let inFence = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const fenceMatch = /^(```|~~~)/.exec(line.trim());
|
||||
if (fenceMatch) {
|
||||
inFence = !inFence;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = lines[i + 1];
|
||||
if (next && isTableRow(line) && isTableDivider(next)) {
|
||||
const header = splitTableRow(line);
|
||||
const divider = splitTableRow(next);
|
||||
const rows: string[][] = [];
|
||||
|
||||
let j = i + 2;
|
||||
while (j < lines.length && isTableRow(lines[j] ?? "") && !isTableDivider(lines[j] ?? "")) {
|
||||
rows.push(splitTableRow(lines[j]!));
|
||||
j++;
|
||||
}
|
||||
|
||||
const alignAttrs = header.map((_, idx) => tableAlignment(divider[idx] ?? ""));
|
||||
const headHtml = header
|
||||
.map((cell, idx) => `<th${alignAttrs[idx] ?? ""}>${renderInlineMarkdown(cell)}</th>`)
|
||||
.join("");
|
||||
const bodyHtml = rows
|
||||
.map((row) => {
|
||||
const cells = header.map((_, idx) => renderInlineMarkdown(row[idx] ?? ""));
|
||||
return `<tr>${cells.map((cell, idx) => `<td${alignAttrs[idx] ?? ""}>${cell}</td>`).join("")}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
out.push("<table>");
|
||||
out.push(`<thead><tr>${headHtml}</tr></thead>`);
|
||||
out.push(`<tbody>${bodyHtml}</tbody>`);
|
||||
out.push("</table>");
|
||||
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown: string) {
|
||||
return micromark(convertGfmTables(markdown), { allowDangerousHtml: true });
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return getLabSummaries().map((lab) => ({ slug: lab.slug }));
|
||||
}
|
||||
|
||||
export default async function LabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const lab = getLabDocument(slug);
|
||||
if (!lab) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { content, data } = lab;
|
||||
const { markdown: markdownWithoutObjectiveDirective, style: objectiveDirectiveStyle } =
|
||||
extractObjectiveStyleDirective(content);
|
||||
const { markdown: markdownWithoutStepDirective, style: stepDirectiveStyle } =
|
||||
extractStepStyleDirective(markdownWithoutObjectiveDirective);
|
||||
const { markdown, style: breakoutDirectiveStyle } = extractBreakoutStyleDirective(
|
||||
markdownWithoutStepDirective,
|
||||
);
|
||||
const styleConfig = data as {
|
||||
objectiveStyle?: unknown;
|
||||
stepStyle?: unknown;
|
||||
breakoutStyle?: unknown;
|
||||
};
|
||||
const objectiveStyle =
|
||||
normalizeObjectiveStyle(styleConfig.objectiveStyle) ?? objectiveDirectiveStyle ?? "divider";
|
||||
const stepStyle = normalizeStepStyle(styleConfig.stepStyle) ?? stepDirectiveStyle ?? "underline";
|
||||
const breakoutStyle =
|
||||
normalizeBreakoutStyle(styleConfig.breakoutStyle) ?? breakoutDirectiveStyle ?? "none";
|
||||
const objectiveSegmentedHtml = segmentObjectiveSections(
|
||||
stripObjectiveDividers(markdownToHtml(markdown)),
|
||||
);
|
||||
const baseHtml = transformOutsideDetails(objectiveSegmentedHtml, (safeHtml) =>
|
||||
annotateStepHeadings(safeHtml, {
|
||||
stripOrdinals: breakoutStyle === "instruction-rails",
|
||||
}),
|
||||
);
|
||||
const htmlContent =
|
||||
breakoutStyle === "none"
|
||||
? baseHtml
|
||||
: transformOutsideDetails(baseHtml, (safeHtml) =>
|
||||
segmentStepSections(markExplicitInstructionElements(safeHtml, {
|
||||
commandPills: breakoutStyle === "command-pills",
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl rounded-lg border border-[#f6d5a5] bg-white p-6 md:p-8">
|
||||
<h1 className="mb-6 text-3xl font-bold text-[#004E78]">{lab.title}</h1>
|
||||
<LabContent
|
||||
className={`lab-content max-w-none objective-style-${objectiveStyle} step-style-${stepStyle} breakout-style-${breakoutStyle}`}
|
||||
html={htmlContent}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { getLabSummaries } from "~/lib/labs";
|
||||
|
||||
export default function LabsIndex() {
|
||||
const labs = getLabSummaries();
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-10">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-[#004E78]">Open Security Labs</h1>
|
||||
<p className="mt-2 text-slate-600">Browse converted markdown and MDX lab content.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{labs.map((lab) => (
|
||||
<Link
|
||||
key={lab.slug}
|
||||
href={`/labs/${lab.slug}`}
|
||||
className="block rounded-lg border border-slate-200 bg-white p-6 transition hover:border-[#F89C27] hover:shadow-sm"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-[#004E78]">{lab.title}</h2>
|
||||
{lab.description ? <p className="mt-2 text-slate-600">{lab.description}</p> : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import "~/styles/globals.css";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SiteHeader } from "~/components/SiteHeader";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Open Security Labs",
|
||||
description: "Open Security lab content and notebook conversions",
|
||||
icons: [{ rel: "icon", url: "/logo.png" }],
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-white text-slate-900">
|
||||
<SiteHeader />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { getLabSummaries } from "~/lib/labs";
|
||||
|
||||
export default function HomePage() {
|
||||
const labs = getLabSummaries();
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-10">
|
||||
<section className="rounded-xl border border-[#f6d5a5] bg-white p-8 shadow-sm">
|
||||
<h1 className="mb-3 text-3xl font-bold text-[#004E78]">
|
||||
Open Security Labs
|
||||
</h1>
|
||||
<p className="max-w-3xl text-slate-700">
|
||||
Markdown-first lab workspace for Open Security notebook conversions.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/labs"
|
||||
className="inline-flex items-center rounded-md bg-[#004E78] px-4 py-2 text-sm font-semibold text-white hover:bg-[#003a5a]"
|
||||
>
|
||||
Browse all labs
|
||||
</Link>
|
||||
<Link
|
||||
href="https://discord.gg/Ma9UZNBxvh"
|
||||
className="inline-flex items-center rounded-md border border-[#F89C27] px-4 py-2 text-sm font-semibold text-[#004E78] hover:bg-[#F89C27] hover:text-white"
|
||||
>
|
||||
Open Security Discord
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-4 text-xl font-semibold text-[#004E78]">
|
||||
Recent Labs
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{labs.slice(0, 6).map((lab) => (
|
||||
<Link
|
||||
key={lab.slug}
|
||||
href={`/labs/${lab.slug}`}
|
||||
className="block rounded-lg border border-slate-200 bg-white p-5 transition hover:border-[#F89C27] hover:shadow-sm"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-[#004E78]">
|
||||
{lab.title}
|
||||
</h3>
|
||||
{lab.description ? (
|
||||
<p className="mt-2 text-sm text-slate-600">{lab.description}</p>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-[#f8c27a] bg-white/95 shadow-sm backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-6 py-3">
|
||||
<Link href="/" className="flex items-center" aria-label="Open Security home">
|
||||
<Image src="/logo-full.png" alt="Open Security" width={150} height={40} priority />
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-5 text-sm font-semibold text-[#004E78]">
|
||||
<Link href="/" className="hover:text-[#F89C27]">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/labs" className="hover:text-[#F89C27]">
|
||||
Labs
|
||||
</Link>
|
||||
<Link
|
||||
href="https://discord.gg/Ma9UZNBxvh"
|
||||
className="rounded-md border border-[#F89C27] px-3 py-1.5 text-[#004E78] hover:bg-[#F89C27] hover:text-white"
|
||||
>
|
||||
Discord
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type LabContentProps = {
|
||||
className: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
const cliLanguagePattern = /\b(language-(bash|sh|shell|zsh|console|terminal)|bash|shell|zsh)\b/i;
|
||||
const cliCommandPattern =
|
||||
/(^|\n)\s*(\$|sudo\s|git\s|python3?\s|pip\s|npm\s|pnpm\s|yarn\s|llama-|ollama\s|curl\s|wget\s|apt\s|cd\s|ls\s|cat\s|cp\s|mv\s|chmod\s|make\s)/i;
|
||||
const promptLanguagePattern = /\b(language-(text|plaintext|md|markdown)|text|plaintext|markdown)\b/i;
|
||||
const promptSignalPattern =
|
||||
/\b(you are|guidelines|follow these|example|when provided|system prompt|tasked with)\b/i;
|
||||
|
||||
type ParsedSetting = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ZoomedImageState = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function looksLikeCliCommand(commandText: string, className: string) {
|
||||
if (cliLanguagePattern.test(className)) return true;
|
||||
return cliCommandPattern.test(commandText) || /--[a-z0-9-]+/i.test(commandText);
|
||||
}
|
||||
|
||||
function looksLikePromptTextBlock(text: string, className: string) {
|
||||
if (looksLikeCliCommand(text, className)) return false;
|
||||
|
||||
const normalizedText = text.trim();
|
||||
if (!normalizedText) return false;
|
||||
|
||||
const lineCount = normalizedText.split("\n").length;
|
||||
if (promptLanguagePattern.test(className) && normalizedText.length > 80) return true;
|
||||
if (lineCount >= 4 && promptSignalPattern.test(normalizedText)) return true;
|
||||
if (lineCount >= 6 && /(^|\n)\s*[*-]\s+/.test(normalizedText)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function parseSettingListItem(item: HTMLLIElement): ParsedSetting | null {
|
||||
const keyElement = item.querySelector("code");
|
||||
if (!keyElement) return null;
|
||||
|
||||
const key = (keyElement.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!key || key.length > 40) return null;
|
||||
|
||||
const text = (item.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
const match = new RegExp(`^${escapeRegex(key)}\\s*(?:-|–|—|:|=)\\s*(.+)$`).exec(text);
|
||||
if (!match) return null;
|
||||
|
||||
const value = (match[1] ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!value || value.length > 36) return null;
|
||||
if (/[.;]/.test(value) && value.length > 16) return null;
|
||||
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
function enhanceSettingsLists(root: HTMLElement) {
|
||||
const lists = root.querySelectorAll<HTMLUListElement>("ul");
|
||||
|
||||
for (const list of lists) {
|
||||
if (list.dataset.settingsEnhanced === "true") continue;
|
||||
|
||||
const items = Array.from(list.children).filter((node): node is HTMLLIElement => {
|
||||
return node.tagName === "LI";
|
||||
});
|
||||
if (items.length < 2) continue;
|
||||
|
||||
const parsedItems = items.map((item) => parseSettingListItem(item));
|
||||
if (parsedItems.some((parsedItem) => parsedItem === null)) continue;
|
||||
|
||||
const settings = parsedItems as ParsedSetting[];
|
||||
const compactValueCount = settings.filter((setting) => setting.value.length <= 20).length;
|
||||
if (compactValueCount < Math.max(2, Math.ceil(settings.length * 0.66))) continue;
|
||||
|
||||
list.dataset.settingsEnhanced = "true";
|
||||
list.classList.add("lab-settings-list");
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const setting = settings[i];
|
||||
if (!item || !setting) continue;
|
||||
|
||||
item.classList.add("lab-settings-item");
|
||||
item.innerHTML =
|
||||
`<span class="lab-setting-key">${escapeHtml(setting.key)}</span>` +
|
||||
`<span class="lab-setting-value">${escapeHtml(setting.value)}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCopyButton(pre: HTMLPreElement, label: string) {
|
||||
if (pre.dataset.copyEnhanced === "true") return;
|
||||
|
||||
pre.dataset.copyEnhanced = "true";
|
||||
const copyButton = document.createElement("button");
|
||||
copyButton.type = "button";
|
||||
copyButton.className = "lab-copy-button";
|
||||
copyButton.textContent = label;
|
||||
copyButton.dataset.defaultLabel = label;
|
||||
copyButton.setAttribute("aria-label", "Copy block to clipboard");
|
||||
pre.appendChild(copyButton);
|
||||
}
|
||||
|
||||
export function LabContent({ className, html }: LabContentProps) {
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
const [zoomedImage, setZoomedImage] = useState<ZoomedImageState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const preBlocks = root.querySelectorAll<HTMLPreElement>("pre");
|
||||
for (const pre of preBlocks) {
|
||||
const code = pre.querySelector<HTMLElement>("code");
|
||||
if (!code) continue;
|
||||
|
||||
const blockText = code.textContent ?? "";
|
||||
if (looksLikeCliCommand(blockText, code.className)) {
|
||||
pre.classList.add("lab-cli-shell");
|
||||
ensureCopyButton(pre, "Copy");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (looksLikePromptTextBlock(blockText, code.className)) {
|
||||
pre.classList.add("lab-prompt-card");
|
||||
ensureCopyButton(pre, "Copy Text");
|
||||
}
|
||||
}
|
||||
|
||||
enhanceSettingsLists(root);
|
||||
|
||||
const handleRootClick = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const button = target.closest<HTMLButtonElement>("button.lab-copy-button");
|
||||
if (button) {
|
||||
const pre = button.closest("pre");
|
||||
const code = pre?.querySelector("code");
|
||||
const commandText = code?.textContent?.trimEnd();
|
||||
if (!commandText) return;
|
||||
const defaultLabel = button.dataset.defaultLabel ?? "Copy";
|
||||
|
||||
void navigator.clipboard.writeText(commandText).then(() => {
|
||||
button.textContent = "Copied";
|
||||
button.classList.add("is-copied");
|
||||
window.setTimeout(() => {
|
||||
button.textContent = defaultLabel;
|
||||
button.classList.remove("is-copied");
|
||||
}, 1200);
|
||||
}).catch(() => {
|
||||
button.textContent = "Failed";
|
||||
window.setTimeout(() => {
|
||||
button.textContent = defaultLabel;
|
||||
}, 1200);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = target.closest<HTMLImageElement>("img");
|
||||
if (!image || !root.contains(image)) return;
|
||||
|
||||
const src = image.getAttribute("src");
|
||||
if (!src) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setZoomedImage({
|
||||
src,
|
||||
alt: image.getAttribute("alt") ?? "",
|
||||
});
|
||||
};
|
||||
|
||||
root.addEventListener("click", handleRootClick);
|
||||
return () => {
|
||||
root.removeEventListener("click", handleRootClick);
|
||||
};
|
||||
}, [html]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!zoomedImage) return;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const previousFocusedElement = activeElement instanceof HTMLElement ? activeElement : null;
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setZoomedImage(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
previousFocusedElement?.focus();
|
||||
};
|
||||
}, [zoomedImage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
{zoomedImage ? (
|
||||
<div
|
||||
className="lab-image-modal"
|
||||
role="presentation"
|
||||
onClick={() => setZoomedImage(null)}
|
||||
>
|
||||
<div className="lab-image-modal__surface" onClick={(event) => event.stopPropagation()}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="lab-image-modal__image" src={zoomedImage.src} alt={zoomedImage.alt} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), "content", "labs");
|
||||
const CONTENT_EXTENSIONS = [".md", ".mdx"] as const;
|
||||
|
||||
export type LabSummary = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
export type LabDocument = LabSummary & {
|
||||
content: string;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function toTitleCaseFromSlug(slug: string) {
|
||||
return slug.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function getSlugFromFileName(fileName: string) {
|
||||
return fileName.replace(/\.(md|mdx)$/i, "");
|
||||
}
|
||||
|
||||
function hasSupportedExtension(fileName: string) {
|
||||
return CONTENT_EXTENSIONS.some((ext) => fileName.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
export function listLabFiles() {
|
||||
if (!fs.existsSync(CONTENT_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(CONTENT_DIR)
|
||||
.filter((fileName) => hasSupportedExtension(fileName))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function getLabSummaries() {
|
||||
return listLabFiles().map((fileName) => {
|
||||
const filePath = path.join(CONTENT_DIR, fileName);
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const { data } = matter(source);
|
||||
const slug = getSlugFromFileName(fileName);
|
||||
|
||||
const title =
|
||||
typeof data.title === "string" && data.title.trim().length > 0
|
||||
? data.title
|
||||
: toTitleCaseFromSlug(slug);
|
||||
|
||||
const description =
|
||||
typeof data.description === "string" && data.description.trim().length > 0
|
||||
? data.description
|
||||
: "";
|
||||
|
||||
return {
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
fileName,
|
||||
} satisfies LabSummary;
|
||||
});
|
||||
}
|
||||
|
||||
export function getLabDocument(slug: string): LabDocument | null {
|
||||
const fileName = listLabFiles().find((candidateFileName) => {
|
||||
return getSlugFromFileName(candidateFileName) === slug;
|
||||
});
|
||||
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = path.join(CONTENT_DIR, fileName);
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const { content, data } = matter(source);
|
||||
|
||||
const title =
|
||||
typeof data.title === "string" && data.title.trim().length > 0
|
||||
? data.title
|
||||
: toTitleCaseFromSlug(slug);
|
||||
|
||||
const description =
|
||||
typeof data.description === "string" && data.description.trim().length > 0
|
||||
? data.description
|
||||
: "";
|
||||
|
||||
return {
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
fileName,
|
||||
content,
|
||||
data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Base CSS Element Custom Styles */
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
color: #004E78;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
margin-bottom: 10px;
|
||||
color: #004E78;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
margin-bottom: 10px;
|
||||
color: #004E78;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
margin-bottom: 10px;
|
||||
color: #004E78;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
margin-bottom: 10px;
|
||||
color: #004E78;
|
||||
}
|
||||
p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-left: 20px;
|
||||
}
|
||||
ol {
|
||||
list-style-type: number;
|
||||
margin-left: 20px;
|
||||
}
|
||||
/* End Basic Customizations */
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: none;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
/* #F89C27 / 34, 94%, 56% // #004E78 / 201 100% 24% */
|
||||
--primary: 34, 94%, 56%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 201 100% 24%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 34, 94%, 56%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 34, 94%, 56%;
|
||||
|
||||
/* Keeping original chart colors */
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
|
||||
/* Keeping original chart colors */
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyframes for landing page */
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.blur {
|
||||
background: radial-gradient(circle, transparent 100%, black);
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.bg-fiber-carbon {
|
||||
background:
|
||||
radial-gradient(black 10%, transparent 1%) 0 0,
|
||||
radial-gradient(rgba(255, 255, 255, 0.1) 15%, transparent 10%) 8px 19px;
|
||||
background-color: #ffff;
|
||||
background-size: 36px 36px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
animation: progress 1s infinite linear;
|
||||
}
|
||||
|
||||
.left-right {
|
||||
transform-origin: 0% 50%;
|
||||
}
|
||||
@keyframes progress {
|
||||
0% {
|
||||
transform: translateX(0) scaleX(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(0) scaleX(0.4);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%) scaleX(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.lab-content table {
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.lab-content th,
|
||||
.lab-content td {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.55rem 0.7rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.lab-content th {
|
||||
background-color: #f3f4f6;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.lab-content img {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.lab-image-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
background: rgba(75, 85, 99, 0.82);
|
||||
}
|
||||
|
||||
.lab-image-modal__surface {
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.lab-image-modal__image {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 18px 56px rgba(17, 24, 39, 0.5);
|
||||
}
|
||||
|
||||
.lab-content .lab-callout {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 4px solid;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.lab-content .lab-callout--warning {
|
||||
border-left-color: #dc2626;
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.lab-content .lab-callout--info {
|
||||
border-left-color: #2563eb;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.lab-content .lab-callout--checkpoint {
|
||||
border-left-color: #15803d;
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-cli-shell {
|
||||
position: relative;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem 1rem 0.85rem;
|
||||
border: 1px solid #c8d9e8;
|
||||
border-left: 5px solid #004e78;
|
||||
border-radius: 10px;
|
||||
background: #f4f9ff;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-cli-shell::before {
|
||||
content: "CLI";
|
||||
position: absolute;
|
||||
top: 0.42rem;
|
||||
left: 0.7rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #4a6477;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-cli-shell code {
|
||||
display: block;
|
||||
margin-top: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-cli-shell .lab-copy-button {
|
||||
position: absolute;
|
||||
top: 0.36rem;
|
||||
right: 0.4rem;
|
||||
border: 1px solid #c3d4e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #294e69;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 0.34rem 0.56rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-cli-shell .lab-copy-button:hover {
|
||||
background: #eef5fb;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-cli-shell .lab-copy-button.is-copied {
|
||||
border-color: #88c09e;
|
||||
color: #0f5d33;
|
||||
background: #eaf8ef;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-prompt-card {
|
||||
position: relative;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem 1rem 0.92rem;
|
||||
border: 1px solid #d7c7a7;
|
||||
border-left: 5px solid #b77400;
|
||||
border-radius: 10px;
|
||||
background: #fffaf2;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-prompt-card::before {
|
||||
content: "Card Description";
|
||||
position: absolute;
|
||||
top: 0.42rem;
|
||||
left: 0.7rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #6c4a12;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-prompt-card code {
|
||||
display: block;
|
||||
margin-top: 0.82rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.lab-content pre.lab-prompt-card .lab-copy-button {
|
||||
position: absolute;
|
||||
top: 0.34rem;
|
||||
right: 0.4rem;
|
||||
border: 1px solid #9b5a00;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #ffd18d, #f3a743);
|
||||
color: #3d2401;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
padding: 0.36rem 0.62rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.35), 0 1px 2px rgba(61, 36, 1, 0.18);
|
||||
}
|
||||
|
||||
.lab-content pre.lab-prompt-card .lab-copy-button:hover {
|
||||
background: linear-gradient(180deg, #ffdb9f, #f5b459);
|
||||
}
|
||||
|
||||
.lab-content pre.lab-prompt-card .lab-copy-button.is-copied {
|
||||
border-color: #4f8d5f;
|
||||
color: #08361a;
|
||||
background: linear-gradient(180deg, #c8f2d4, #9edcb3);
|
||||
}
|
||||
|
||||
.lab-content ul.lab-settings-list {
|
||||
list-style: none;
|
||||
margin: 0.9rem 0 1.2rem;
|
||||
margin-left: 0;
|
||||
padding: 0.22rem 0;
|
||||
border: 1px solid #ccdeec;
|
||||
border-left: 5px solid #0b72ba;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #f9fcff, #f4f9fe);
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.lab-content ul.lab-settings-list > li.lab-settings-item {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border: none;
|
||||
border-bottom: 1px dashed #d5e3ef;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.lab-content ul.lab-settings-list > li.lab-settings-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lab-content ul.lab-settings-list .lab-setting-key {
|
||||
font-weight: 600;
|
||||
color: #0b4e77;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.lab-content ul.lab-settings-list .lab-setting-value {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: #1f425f;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lab-content hr {
|
||||
margin: 2rem 0 1.4rem;
|
||||
border-color: #d7dee6;
|
||||
}
|
||||
|
||||
.lab-content .objective-segment {
|
||||
position: relative;
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
|
||||
.lab-content .objective-segment > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.lab-content .objective-segment > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.lab-content .objective-segment > h2 {
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
||||
|
||||
.lab-content.objective-style-divider .objective-segment {
|
||||
padding-top: 1.55rem;
|
||||
}
|
||||
|
||||
.lab-content.objective-style-divider .objective-segment::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 78, 120, 0.08),
|
||||
rgba(0, 78, 120, 0.45) 12%,
|
||||
rgba(0, 78, 120, 0.45) 88%,
|
||||
rgba(0, 78, 120, 0.08)
|
||||
);
|
||||
}
|
||||
|
||||
.lab-content.objective-style-cards .objective-segment {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem 1.2rem 1.2rem;
|
||||
border: 1px solid #d8e1ea;
|
||||
border-left: 6px solid #f89c27;
|
||||
border-radius: 12px;
|
||||
background: #f9fbfd;
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.lab-content.objective-style-cards .objective-segment > h2 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.lab-content.objective-style-rail .objective-segment {
|
||||
margin: 1.8rem 0;
|
||||
padding: 0.4rem 0 0.5rem 1.15rem;
|
||||
border-left: 4px solid #004e78;
|
||||
background: linear-gradient(90deg, rgba(0, 78, 120, 0.08), rgba(0, 78, 120, 0));
|
||||
}
|
||||
|
||||
.lab-content.objective-style-rail .objective-segment > h2 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.lab-content.objective-style-rail .objective-segment > h2::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 4.5rem;
|
||||
height: 0.2rem;
|
||||
margin-top: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background-color: #f89c27;
|
||||
}
|
||||
|
||||
.lab-content .lab-step-title {
|
||||
margin-top: 1.2rem;
|
||||
margin-bottom: 0.55rem;
|
||||
line-height: 1.35;
|
||||
color: #003f61;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.lab-content .lab-step-title + p,
|
||||
.lab-content .lab-step-title + ul,
|
||||
.lab-content .lab-step-title + ol,
|
||||
.lab-content .lab-step-title + pre,
|
||||
.lab-content .lab-step-title + blockquote,
|
||||
.lab-content .lab-step-title + figure {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.lab-content.step-style-underline .lab-step-title {
|
||||
padding-bottom: 0.35rem;
|
||||
border-bottom: 1px solid #d9e3ec;
|
||||
}
|
||||
|
||||
.lab-content.step-style-underline .lab-step-title[data-step-mode="execute"] {
|
||||
border-bottom-color: #f8cc8f;
|
||||
}
|
||||
|
||||
.lab-content.step-style-underline .lab-step-title[data-step-mode="explore"] {
|
||||
border-bottom-color: #9bc9ee;
|
||||
}
|
||||
|
||||
.lab-content.step-style-underline .lab-step-title[data-step-mode="checkpoint"] {
|
||||
border-bottom-color: #86d4a4;
|
||||
}
|
||||
|
||||
.lab-content.step-style-pills .lab-step-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.42rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #d8e2eb;
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.lab-content.step-style-pills .lab-step-title::before {
|
||||
content: attr(data-step-mode);
|
||||
text-transform: capitalize;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
color: #1a4f72;
|
||||
background: #e2eef8;
|
||||
}
|
||||
|
||||
.lab-content.step-style-pills .lab-step-title:not([data-step-mode])::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.lab-content.step-style-pills .lab-step-title[data-step-mode="execute"]::before {
|
||||
color: #8a4d00;
|
||||
background: #fee7c7;
|
||||
}
|
||||
|
||||
.lab-content.step-style-pills .lab-step-title[data-step-mode="explore"]::before {
|
||||
color: #0f4970;
|
||||
background: #d9ebfb;
|
||||
}
|
||||
|
||||
.lab-content.step-style-pills .lab-step-title[data-step-mode="checkpoint"]::before {
|
||||
color: #0e5e35;
|
||||
background: #d9f7e4;
|
||||
}
|
||||
|
||||
.lab-content.step-style-blocks .lab-step-title {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid #d8e2eb;
|
||||
border-left: 5px solid #004e78;
|
||||
border-radius: 10px;
|
||||
background: #f9fbfd;
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.lab-content.step-style-blocks .lab-step-title[data-step-mode="execute"] {
|
||||
border-left-color: #cc7a00;
|
||||
background: #fffbf5;
|
||||
}
|
||||
|
||||
.lab-content.step-style-blocks .lab-step-title[data-step-mode="explore"] {
|
||||
border-left-color: #0b72ba;
|
||||
background: #f6fbff;
|
||||
}
|
||||
|
||||
.lab-content.step-style-blocks .lab-step-title[data-step-mode="checkpoint"] {
|
||||
border-left-color: #198754;
|
||||
background: #f6fffa;
|
||||
}
|
||||
|
||||
.lab-content .step-segment {
|
||||
position: relative;
|
||||
margin: 1rem 0 1.35rem;
|
||||
}
|
||||
|
||||
.lab-content .step-segment > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.lab-content .step-segment > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.lab-content .step-segment[data-step-kind]::before {
|
||||
display: inline-block;
|
||||
font-size: 0.63rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-panel .step-segment {
|
||||
padding: 0.75rem 0.9rem 0.9rem;
|
||||
border: 1px solid #dce7f1;
|
||||
border-radius: 12px;
|
||||
background: #f8fbfe;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-panel .step-segment[data-step-kind="explanation"] {
|
||||
border-left: 5px solid #2b7fbf;
|
||||
background: #f5fafe;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-panel .step-segment[data-step-kind="instruction"] {
|
||||
border-left: 5px solid #d48806;
|
||||
background: #fffaf2;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-panel .step-segment[data-step-kind="mixed"] {
|
||||
border-left: 5px solid #0e7490;
|
||||
background: linear-gradient(90deg, #f6fbff, #fffbf5);
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-panel .step-segment[data-step-kind]::before {
|
||||
color: #315168;
|
||||
content: attr(data-step-kind);
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-workflow .step-segment {
|
||||
padding: 0.45rem 0 0.65rem 1rem;
|
||||
border-left: 2px dashed #c6d5e3;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-workflow .step-segment[data-step-kind="explanation"] {
|
||||
border-left-color: #6da9d8;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-workflow .step-segment[data-step-kind="instruction"] {
|
||||
border-left-color: #de9a2e;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-workflow .step-segment[data-step-kind="mixed"] {
|
||||
border-left-color: #4a95ab;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-workflow .step-segment[data-step-kind]::before {
|
||||
width: 1.2rem;
|
||||
text-indent: -9999px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
margin-left: -1.4rem;
|
||||
margin-right: 0.35rem;
|
||||
vertical-align: middle;
|
||||
background: #6da9d8;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-workflow .step-segment[data-step-kind="instruction"]::before {
|
||||
background: #de9a2e;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-workflow .step-segment[data-step-kind="mixed"]::before {
|
||||
background: #4a95ab;
|
||||
}
|
||||
|
||||
.lab-content .explicit-command-block {
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.lab-content .explicit-command .cmd-pill {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills .step-segment {
|
||||
padding: 0.3rem 0 0.45rem;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills .step-segment[data-step-kind="instruction"] {
|
||||
border-left: 3px solid #f0b45f;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills .step-segment[data-step-kind="explanation"] {
|
||||
border-left: 3px solid #8dc1e7;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills .step-segment[data-step-kind="mixed"] {
|
||||
border-left: 3px solid #6db0bf;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills .step-segment[data-step-kind]::before {
|
||||
color: #4a6477;
|
||||
content: attr(data-step-kind);
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-instruction-rails .step-segment {
|
||||
padding: 0.3rem 0 0.45rem 0.9rem;
|
||||
border-left: none;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-instruction-rails .step-segment::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.45rem;
|
||||
height: calc(100% - 1.05rem);
|
||||
width: 4px;
|
||||
border-radius: 999px;
|
||||
background: #6db0bf;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-instruction-rails .step-segment[data-step-kind="instruction"]::after {
|
||||
background: #f0b45f;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-instruction-rails .step-segment[data-step-kind="explanation"]::after {
|
||||
background: #8dc1e7;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-instruction-rails .step-segment[data-step-kind]::before {
|
||||
color: #4a6477;
|
||||
content: attr(data-step-kind);
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-instruction-rails .lab-step-title {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.7rem;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills p.explicit-instruction,
|
||||
.lab-content.breakout-style-command-pills li.explicit-instruction {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #f2d2a0;
|
||||
background: #fff8ec;
|
||||
padding: 0.22rem 0.68rem;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills ol > li.explicit-instruction::marker,
|
||||
.lab-content.breakout-style-command-pills ul > li.explicit-instruction::marker {
|
||||
color: #a66300;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills .explicit-command-block {
|
||||
padding: 0.25rem 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills .explicit-command {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
white-space: normal;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-command-pills .explicit-command .cmd-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e5bf85;
|
||||
background: #fff1d8;
|
||||
padding: 0.2rem 0.58rem;
|
||||
line-height: 1.25;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.lab-content ul.concept-pill-list {
|
||||
list-style: none;
|
||||
margin: 0.9rem 0 1.2rem;
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.lab-content ul.concept-pill-list > li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
padding: 0.48rem 0.78rem;
|
||||
border: 1px solid #d5e2ee;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #f9fcff, #f4f9fe);
|
||||
}
|
||||
|
||||
.lab-content .concept-pill-label {
|
||||
display: inline;
|
||||
color: #0f4f76;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: none;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lab-content.objective-style-cards .objective-segment {
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.lab-content.objective-style-rail .objective-segment {
|
||||
padding-left: 0.85rem;
|
||||
}
|
||||
|
||||
.lab-content.step-style-pills .lab-step-title {
|
||||
border-radius: 12px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
}
|
||||
|
||||
.lab-content.step-style-blocks .lab-step-title {
|
||||
padding: 0.62rem 0.75rem;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-panel .step-segment {
|
||||
padding: 0.68rem 0.72rem 0.78rem;
|
||||
}
|
||||
|
||||
.lab-content.breakout-style-workflow .step-segment,
|
||||
.lab-content.breakout-style-command-pills .step-segment,
|
||||
.lab-content.breakout-style-instruction-rails .step-segment {
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
|
||||
.lab-content ul.lab-settings-list > li.lab-settings-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.28rem;
|
||||
align-items: start;
|
||||
padding: 0.52rem 0.75rem;
|
||||
}
|
||||
|
||||
.lab-content ul.lab-settings-list .lab-setting-value {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.lab-content ul.concept-pill-list > li {
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user