mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-11 13:28:19 +00:00
Compare commits
5
Commits
main
...
add-mcp-page
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc3310b87 | ||
|
|
6040f48671 | ||
|
|
7e787531e7 | ||
|
|
2e18b6568d | ||
|
|
19e55402c3 |
@@ -48,6 +48,12 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
// The waitlist forms POST same-origin, and the API dev server has CORS turned off.
|
||||
proxy: {
|
||||
'/api/waitlist': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
@@ -89,6 +95,8 @@ export default defineConfig({
|
||||
'packages.md',
|
||||
'showcase.md',
|
||||
'sponsor.md',
|
||||
'mcp.md',
|
||||
'waitlist/confirmed.md',
|
||||
'brand-logo-statement.md',
|
||||
'icons/**', // Not working, need investigation
|
||||
],
|
||||
@@ -118,6 +126,7 @@ export default defineConfig({
|
||||
},
|
||||
{ text: 'Packages', link: '/packages' },
|
||||
{ text: 'Showcase', link: '/showcase' },
|
||||
{ text: 'MCP <span class="nav-badge">SOON</span>', link: '/mcp' },
|
||||
{ text: 'Merch', link: 'https://merch.lucide.dev/' },
|
||||
],
|
||||
sidebar,
|
||||
@@ -150,5 +159,6 @@ export default defineConfig({
|
||||
},
|
||||
sitemap: {
|
||||
hostname: 'https://lucide.dev/',
|
||||
transformItems: (items) => items.filter(({ url }) => !url.startsWith('waitlist/')),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
useAnimate,
|
||||
useInView,
|
||||
useReducedMotion,
|
||||
type AnimationPlaybackControls,
|
||||
type AnimationSequence,
|
||||
} from 'motion-v';
|
||||
import Icon from '@lucide/vue/src/Icon';
|
||||
import { bot, cloud, heart, moon, star, zap } from '../../../data/iconNodes';
|
||||
|
||||
/** All timings are in seconds from the start of a loop. */
|
||||
const TRAVEL = 1.7;
|
||||
const DOT_FADE = 0.15;
|
||||
const DASH_FADE = 0.4;
|
||||
const LABEL_FADE = 0.35;
|
||||
const RESET_FADE = 0.5;
|
||||
|
||||
const CALL_GLOW_START = 0.3;
|
||||
const CALL_GLOW = 0.7;
|
||||
const REQUEST_START = 0.85;
|
||||
const REQUEST_END = REQUEST_START + TRAVEL;
|
||||
const SERVER_GLOW = 0.95;
|
||||
const RESPONSE_START = 3.2;
|
||||
const RESPONSE_END = RESPONSE_START + TRAVEL;
|
||||
const RESPONSE_GLOW = 1;
|
||||
const RESET_START = RESPONSE_END + RESPONSE_GLOW;
|
||||
const RESET_END = RESET_START + RESET_FADE;
|
||||
const REST = 0.6;
|
||||
|
||||
/** A multiple of the 8-unit dash period, so the dashes stop on the same phase they started on. */
|
||||
const DASH_TRAVEL = 64;
|
||||
|
||||
const ICON_NAMES = [
|
||||
'heart',
|
||||
'star',
|
||||
'sparkles',
|
||||
'rocket',
|
||||
'bell',
|
||||
'zap',
|
||||
'coffee',
|
||||
'camera',
|
||||
'moon',
|
||||
'sun',
|
||||
'leaf',
|
||||
'flame',
|
||||
'anchor',
|
||||
'feather',
|
||||
'globe',
|
||||
'crown',
|
||||
];
|
||||
const RESULT_COUNT = 4;
|
||||
const SVG_ICONS = [cloud, heart, moon, star, zap];
|
||||
const JSX_PACKAGES = ['lucide-react', 'lucide-preact', '@lucide/solid'];
|
||||
const PROMPTS = [
|
||||
'"Add lucide-react and\n swap the navbar icons"',
|
||||
'"Set up @lucide/vue\n in this project"',
|
||||
'"Replace inline SVGs\n with @lucide/svelte"',
|
||||
'"Migrate our icons\n to @lucide/angular"',
|
||||
];
|
||||
|
||||
const pick = <T,>(items: T[]) => items[Math.floor(Math.random() * items.length)];
|
||||
|
||||
const iconList = () =>
|
||||
JSON.stringify(
|
||||
[...ICON_NAMES].sort(() => Math.random() - 0.5).slice(0, RESULT_COUNT),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const svgCode = () => {
|
||||
const [[, { d }]] = pick(SVG_ICONS);
|
||||
|
||||
return `<svg viewBox="0 0 24 24">\n <path d="${d.slice(0, 9)}…" />\n</svg>`;
|
||||
};
|
||||
|
||||
const jsxCode = () => {
|
||||
const name = pick(ICON_NAMES);
|
||||
const component = name[0].toUpperCase() + name.slice(1);
|
||||
|
||||
return `import { ${component} }\n from '${pick(JSX_PACKAGES)}';\n\nconst App = () => (\n <${component} size={24} />\n);`;
|
||||
};
|
||||
|
||||
/** One tool per loop, in this order. */
|
||||
const TOOLS = [
|
||||
{ name: 'search_icons', getResult: iconList },
|
||||
{ name: 'get_svg', getResult: svgCode },
|
||||
{ name: 'get_prompts_suggestions', getResult: () => pick(PROMPTS) },
|
||||
{ name: 'get_code', getResult: jsxCode },
|
||||
];
|
||||
|
||||
const toolIndex = ref(0);
|
||||
const toolName = computed(() => TOOLS[toolIndex.value].name);
|
||||
|
||||
// Empty until the first response is due, so the server and client render the same text
|
||||
const result = ref('');
|
||||
|
||||
/** Function segments are called with their progress on every frame, so this guards to run once per loop. */
|
||||
const oncePerLoop = (callback: () => void) => {
|
||||
let called = false;
|
||||
|
||||
return (progress: number) => {
|
||||
if (progress < 1) {
|
||||
called = false;
|
||||
} else if (!called) {
|
||||
called = true;
|
||||
callback();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const travel =(selector: string, at: number, y: [string, string]): AnimationSequence => [
|
||||
[selector, { y }, { at, duration: TRAVEL, ease: 'easeInOut' }],
|
||||
[selector, { opacity: [0, 1] }, { at, duration: DOT_FADE }],
|
||||
[selector, { opacity: [1, 0] }, { at: at + TRAVEL - DOT_FADE, duration: DOT_FADE }],
|
||||
];
|
||||
|
||||
const SEQUENCE: AnimationSequence = [
|
||||
// Your AI Tool makes the call
|
||||
['.node-client .glow', { opacity: [0, 1, 0] }, { at: CALL_GLOW_START, duration: CALL_GLOW }],
|
||||
['.line line', { strokeDasharray: ['8 0', '4 4'] }, { at: CALL_GLOW_START, duration: DASH_FADE }],
|
||||
[
|
||||
'.line line',
|
||||
{ strokeDashoffset: [0, DASH_TRAVEL] },
|
||||
{ at: CALL_GLOW_START, duration: REQUEST_END - CALL_GLOW_START, ease: 'linear' },
|
||||
],
|
||||
['.tool-call', { opacity: [0, 1], scale: [0.9, 1] }, { at: CALL_GLOW_START, duration: LABEL_FADE }],
|
||||
|
||||
// The request travels up to Lucide MCP
|
||||
...travel('.dot-request', REQUEST_START, ['100%', '0%']),
|
||||
|
||||
// Lucide MCP receives it
|
||||
['.node-server .glow', { opacity: [0, 1, 0] }, { at: REQUEST_END, duration: SERVER_GLOW }],
|
||||
[
|
||||
oncePerLoop(() => {
|
||||
result.value = TOOLS[toolIndex.value].getResult();
|
||||
}),
|
||||
{ at: REQUEST_END, duration: 0.05 },
|
||||
],
|
||||
|
||||
// The response travels back down, and the label swaps to the result
|
||||
...travel('.dot-response', RESPONSE_START, ['0%', '100%']),
|
||||
[
|
||||
'.line line',
|
||||
{ strokeDashoffset: [DASH_TRAVEL, 0] },
|
||||
{ at: RESPONSE_START, duration: TRAVEL, ease: 'linear' },
|
||||
],
|
||||
['.tool-call', { opacity: [1, 0] }, { at: RESPONSE_START, duration: LABEL_FADE }],
|
||||
[
|
||||
'.tool-result',
|
||||
{ opacity: [0, 1], scale: [0.9, 1] },
|
||||
{ at: RESPONSE_START, duration: LABEL_FADE },
|
||||
],
|
||||
|
||||
// Your AI Tool receives the response
|
||||
['.node-client .glow', { opacity: [0, 1, 0] }, { at: RESPONSE_END, duration: RESPONSE_GLOW }],
|
||||
|
||||
// Back to the rest state
|
||||
['.tool-result', { opacity: [1, 0] }, { at: RESET_START, duration: RESET_FADE }],
|
||||
['.line line', { strokeDasharray: ['4 4', '8 0'] }, { at: RESET_START, duration: RESET_FADE }],
|
||||
|
||||
// Both labels are hidden now, so the next loop can use the next tool
|
||||
[
|
||||
oncePerLoop(() => {
|
||||
toolIndex.value = (toolIndex.value + 1) % TOOLS.length;
|
||||
}),
|
||||
{ at: RESET_END, duration: 0.05 },
|
||||
],
|
||||
];
|
||||
|
||||
const [scope, animate] = useAnimate();
|
||||
const inView = useInView(() => scope.value as HTMLElement, { amount: 0.3 });
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
let controls: AnimationPlaybackControls | undefined;
|
||||
|
||||
watch(inView, (isInView) => {
|
||||
if (reducedMotion.value) return;
|
||||
|
||||
if (!isInView) {
|
||||
controls?.pause();
|
||||
} else if (controls) {
|
||||
controls.play();
|
||||
} else {
|
||||
controls = animate(SEQUENCE, { repeat: Infinity, repeatDelay: REST });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="scope"
|
||||
class="diagram"
|
||||
role="img"
|
||||
aria-label="Your AI tool calls Lucide MCP through tools such as search_icons."
|
||||
>
|
||||
<div class="node node-server">
|
||||
<span class="glow" />
|
||||
<span class="node-icon">
|
||||
<Icon
|
||||
:iconNode="cloud"
|
||||
name="cloud"
|
||||
:size="24"
|
||||
/>
|
||||
</span>
|
||||
<span class="node-title">Lucide MCP</span>
|
||||
</div>
|
||||
|
||||
<div class="connector">
|
||||
<svg
|
||||
class="line"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<line
|
||||
x1="0.5"
|
||||
y1="0"
|
||||
x2="0.5"
|
||||
y2="100%"
|
||||
/>
|
||||
</svg>
|
||||
<span class="dot dot-request" />
|
||||
<span class="dot dot-response" />
|
||||
<span class="tool tool-call">{{ toolName }}</span>
|
||||
<span class="tool tool-result">{{ result }}</span>
|
||||
</div>
|
||||
|
||||
<div class="node node-client">
|
||||
<span class="glow" />
|
||||
<span class="node-icon">
|
||||
<Icon
|
||||
:iconNode="bot"
|
||||
name="bot"
|
||||
:size="24"
|
||||
/>
|
||||
</span>
|
||||
<span class="node-title">Your AI Tool</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diagram {
|
||||
width: 230px;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--vp-c-brand-3),
|
||||
0 0 24px -2px var(--vp-c-brand-3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-brand-3);
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.connector {
|
||||
position: relative;
|
||||
height: 278px;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.line line {
|
||||
stroke: var(--vp-c-border);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 8 0;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
/* Spans the whole connector, so a percentage `y` is relative to its height at every breakpoint */
|
||||
.dot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--dot-color);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.dot-request {
|
||||
--dot-color: var(--vp-c-brand-3);
|
||||
}
|
||||
|
||||
.dot-response {
|
||||
--dot-color: var(--vp-a11y-success);
|
||||
}
|
||||
|
||||
.dot-response::after {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Centred with `translate`, because motion owns this element's `transform` */
|
||||
.tool {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
translate: -50% -50%;
|
||||
font-size: 21px;
|
||||
line-height: 17px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
text-shadow: 0 4px 20px var(--vp-c-bg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.tool-call, .tool-result {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
.connector {
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.line line {
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dot-request {
|
||||
opacity: 1;
|
||||
transform: translateY(74%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import Card from '../base/Card.vue';
|
||||
import WaitlistSurvey from './WaitlistSurvey.vue';
|
||||
import { waitlistProducts, type WaitlistProduct } from './products';
|
||||
import './waitlist.css';
|
||||
|
||||
const status = ref<'pending' | 'confirmed' | 'invalid'>('pending');
|
||||
const product = ref<WaitlistProduct>();
|
||||
|
||||
// This page is prerendered, so the query string is only available after hydration.
|
||||
onMounted(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const productId = params.get('product');
|
||||
|
||||
product.value =
|
||||
productId && Object.hasOwn(waitlistProducts, productId)
|
||||
? waitlistProducts[productId]
|
||||
: undefined;
|
||||
status.value = product.value && !params.has('status') ? 'confirmed' : 'invalid';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="waitlist-confirmed">
|
||||
<Card
|
||||
v-if="status === 'confirmed' && product"
|
||||
class="waitlist-card"
|
||||
role="status"
|
||||
>
|
||||
<div class="waitlist-card-header">
|
||||
<h1 class="waitlist-card-title">You're on the waitlist</h1>
|
||||
<p class="waitlist-card-description">
|
||||
Thanks for confirming. We'll email you launch updates for {{ product.name }}.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<WaitlistSurvey
|
||||
v-if="status === 'confirmed' && product"
|
||||
:product="product"
|
||||
/>
|
||||
|
||||
<Card
|
||||
v-if="status === 'invalid'"
|
||||
class="waitlist-card"
|
||||
>
|
||||
<div class="waitlist-card-header">
|
||||
<h1 class="waitlist-card-title">This link has expired</h1>
|
||||
<p class="waitlist-card-description">
|
||||
Your confirmation link is invalid or too old.
|
||||
<a
|
||||
class="waitlist-link"
|
||||
:href="product?.path ?? '/mcp'"
|
||||
>Join the waitlist again</a
|
||||
>
|
||||
to get a new one.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.waitlist-confirmed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-width: 576px;
|
||||
min-height: calc(100vh - var(--vp-nav-height) - 113px);
|
||||
margin: 0 auto;
|
||||
padding: 80px 24px 64px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
|
||||
import Card from '../base/Card.vue';
|
||||
import Input from '../base/Input.vue';
|
||||
import { track } from '../../utils/openpanel';
|
||||
import type { WaitlistProduct } from './products';
|
||||
import './waitlist.css';
|
||||
|
||||
const props = defineProps<{
|
||||
product: WaitlistProduct;
|
||||
}>();
|
||||
|
||||
const ERROR_MESSAGES: Record<number, string> = {
|
||||
400: 'Enter a valid email address.',
|
||||
429: 'Too many attempts. Wait a minute and try again.',
|
||||
};
|
||||
const GENERIC_ERROR = 'Something went wrong. Please try again.';
|
||||
|
||||
const email = ref('');
|
||||
const useCase = ref('');
|
||||
const website = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const isSubmitted = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const hasEmailError = ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
isSubmitting.value = true;
|
||||
errorMessage.value = '';
|
||||
hasEmailError.value = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email.value.trim(),
|
||||
product: props.product.id,
|
||||
use_case: useCase.value.trim(),
|
||||
website: website.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
track('waitlist_submit', { product: props.product.id });
|
||||
isSubmitted.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = ERROR_MESSAGES[response.status] ?? GENERIC_ERROR;
|
||||
hasEmailError.value = response.status === 400;
|
||||
} catch {
|
||||
errorMessage.value = GENERIC_ERROR;
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="waitlist-card">
|
||||
<div
|
||||
v-if="isSubmitted"
|
||||
class="waitlist-card-header"
|
||||
role="status"
|
||||
>
|
||||
<h2 class="waitlist-card-title">Check your inbox</h2>
|
||||
<p class="waitlist-card-description">
|
||||
We sent a confirmation link to <strong>{{ email.trim() }}</strong
|
||||
>. Click it to join the waitlist for {{ product.name }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="waitlist-card-header">
|
||||
<h2 class="waitlist-card-title">Join the waitlist</h2>
|
||||
<p class="waitlist-card-description">{{ product.cardDescription }}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="waitlist-form"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="waitlist-field">
|
||||
<label
|
||||
class="waitlist-label"
|
||||
for="waitlist-email"
|
||||
>Email</label
|
||||
>
|
||||
<Input
|
||||
id="waitlist-email"
|
||||
v-model="email"
|
||||
class="waitlist-input"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
:aria-invalid="hasEmailError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="waitlist-field">
|
||||
<label
|
||||
class="waitlist-label"
|
||||
for="waitlist-use-case"
|
||||
>
|
||||
{{ product.useCaseLabel }}
|
||||
<span class="waitlist-label-hint">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="waitlist-use-case"
|
||||
v-model="useCase"
|
||||
class="waitlist-input"
|
||||
type="text"
|
||||
name="use_case"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
:placeholder="product.useCasePlaceholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Honeypot: people never see this field, so anything in it came from a bot. -->
|
||||
<div
|
||||
class="honeypot"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<label>
|
||||
Website
|
||||
<input
|
||||
v-model="website"
|
||||
type="text"
|
||||
name="website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="waitlist-actions">
|
||||
<VPButton
|
||||
class="waitlist-button"
|
||||
type="submit"
|
||||
size="big"
|
||||
:text="isSubmitting ? 'Joining…' : 'Join the waitlist'"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<div aria-live="polite">
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
class="waitlist-error"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="privacy-note">
|
||||
We store your email and answer to send you launch updates for {{ product.name }}.
|
||||
Unsubscribe anytime.
|
||||
<a
|
||||
class="waitlist-link"
|
||||
href="/privacy"
|
||||
>Privacy statement</a
|
||||
>
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.honeypot {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.privacy-note .waitlist-link {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import Badge from '../base/Badge.vue';
|
||||
import WaitlistForm from './WaitlistForm.vue';
|
||||
import { waitlistProducts } from './products';
|
||||
|
||||
const props = defineProps<{
|
||||
product: keyof typeof waitlistProducts;
|
||||
}>();
|
||||
|
||||
const config = computed(() => waitlistProducts[props.product]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="waitlist-hero">
|
||||
<div class="inner">
|
||||
<div class="intro">
|
||||
<Badge class="tag">{{ config.badge }}</Badge>
|
||||
<h1 class="title">
|
||||
{{ config.titlePrefix }} <span class="accent">{{ config.titleAccent }}</span>
|
||||
</h1>
|
||||
<p class="subtitle">{{ config.subtitle }}</p>
|
||||
<WaitlistForm :product="config" />
|
||||
</div>
|
||||
|
||||
<div class="visual">
|
||||
<slot name="visual" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.waitlist-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 48px 24px 64px;
|
||||
background: var(--vp-sidebar-bg-color);
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 48px;
|
||||
width: 100%;
|
||||
max-width: calc(var(--vp-layout-max-width) - 64px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
max-width: 576px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 4px 8px;
|
||||
line-height: 24px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 16px 0 0;
|
||||
font-size: 40px;
|
||||
line-height: 48px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.4px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.accent {
|
||||
color: var(--vp-c-brand-3);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 12px 0 32px;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.visual {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.title {
|
||||
font-size: 56px;
|
||||
line-height: 64px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.waitlist-hero {
|
||||
min-height: calc(100vh - var(--vp-nav-height) - 113px);
|
||||
padding: 80px 32px 64px;
|
||||
}
|
||||
|
||||
.inner {
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
flex: 0 1 576px;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.visual {
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import Card from '../base/Card.vue';
|
||||
import type { WaitlistProduct } from './products';
|
||||
import './waitlist.css';
|
||||
|
||||
defineProps<{
|
||||
product: WaitlistProduct;
|
||||
}>();
|
||||
|
||||
/** Without Tally's script the embed still works, it just doesn't resize to its content. */
|
||||
function loadEmbeds() {
|
||||
if (window.Tally) {
|
||||
window.Tally.loadEmbeds();
|
||||
return;
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLIFrameElement>('iframe[data-tally-src]:not([src])')
|
||||
.forEach((iframe) => {
|
||||
iframe.src = iframe.dataset.tallySrc ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (window.Tally) {
|
||||
loadEmbeds();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://tally.so/widgets/embed.js';
|
||||
script.onload = loadEmbeds;
|
||||
script.onerror = loadEmbeds;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="waitlist-card survey-card">
|
||||
<iframe
|
||||
:data-tally-src="product.survey.embedSrc"
|
||||
loading="lazy"
|
||||
width="100%"
|
||||
height="857"
|
||||
frameborder="0"
|
||||
marginheight="0"
|
||||
marginwidth="0"
|
||||
:title="product.survey.title"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The Tally form has dark text on a transparent background, so it needs a light card in dark mode. */
|
||||
html.dark div.waitlist-card.survey-card.card {
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface WaitlistProduct {
|
||||
/** Sent to the API as `product` and to analytics. */
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
titlePrefix: string;
|
||||
titleAccent: string;
|
||||
badge: string;
|
||||
subtitle: string;
|
||||
cardDescription: string;
|
||||
useCaseLabel: string;
|
||||
useCasePlaceholder: string;
|
||||
survey: {
|
||||
title: string;
|
||||
embedSrc: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const waitlistProducts: Record<string, WaitlistProduct> = {
|
||||
mcp: {
|
||||
id: 'mcp',
|
||||
path: '/mcp',
|
||||
name: 'Lucide MCP',
|
||||
titlePrefix: 'Lucide',
|
||||
titleAccent: 'MCP',
|
||||
badge: 'SOON',
|
||||
subtitle:
|
||||
'An MCP server that lets your coding agent search every Lucide icon and write the code for the framework you already use.',
|
||||
cardDescription: "You'll get one email to confirm, then launch updates for Lucide MCP only.",
|
||||
useCaseLabel: 'Which AI tool would you use it with?',
|
||||
useCasePlaceholder: 'e.g. Claude Code, Cursor, Copilot',
|
||||
survey: {
|
||||
title: 'Help shape Lucide MCP',
|
||||
embedSrc:
|
||||
'https://tally.so/embed/4aJ4xr?alignLeft=1&transparentBackground=1&dynamicHeight=1&formEventsForwarding=1',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/* The element and base-class selectors outrank the scoped styles of Card and Input. */
|
||||
div.waitlist-card.card {
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
padding: 32px;
|
||||
box-shadow: 0 5px 11px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.waitlist-card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.waitlist-card-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.waitlist-card-description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.waitlist-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.waitlist-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.waitlist-label {
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.waitlist-label-hint {
|
||||
font-weight: 400;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
input.waitlist-input.input {
|
||||
height: 44px;
|
||||
border-color: var(--vp-c-border);
|
||||
background-color: var(--vp-c-bg);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.waitlist-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.waitlist-button {
|
||||
--vp-button-brand-bg: var(--vp-c-brand-5);
|
||||
--vp-button-brand-hover-bg: var(--vp-c-brand-4);
|
||||
--vp-button-brand-active-bg: var(--vp-c-brand-5);
|
||||
}
|
||||
|
||||
.waitlist-button:disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waitlist-error {
|
||||
margin: 12px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.waitlist-link {
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.waitlist-link:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
@@ -246,6 +246,41 @@ html::-webkit-scrollbar-thumb:active {
|
||||
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Small label next to a nav item, set through the item's `text` in the site config. */
|
||||
.nav-badge {
|
||||
display: inline-block;
|
||||
margin-left: 2px;
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
background-color: color-mix(in srgb, var(--vp-c-indigo-1) 10%, transparent);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-indigo-2);
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
/* No room for the label next to the logo at these widths. */
|
||||
@media (min-width: 768px) and (max-width: 879.98px) {
|
||||
.VPNavBarMenu .nav-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The menu only just fits next to the logo at these widths. */
|
||||
@media (min-width: 768px) and (max-width: 829.98px) {
|
||||
.VPNav .VPNavBarMenu .VPNavBarMenuLink,
|
||||
.VPNav .VPNavBarMenuGroup .button {
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.VPNav .VPNavBarMenuLink::before,
|
||||
.VPNav .VPNavBarMenuGroup .button::before {
|
||||
right: 9px;
|
||||
left: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.VPNav .VPNavBarMenuLink:hover::before,
|
||||
.VPNav .VPNavBarMenuLink:focus-visible::before,
|
||||
.VPNav .VPNavBarMenuLink.active::before,
|
||||
|
||||
@@ -108,12 +108,18 @@ export function setupTracking(router: Router) {
|
||||
window.addEventListener('pagehide', trackScrollDepth);
|
||||
window.addEventListener('beforeunload', trackScrollDepth);
|
||||
|
||||
// The hero actions are rendered by VitePress from the home page frontmatter,
|
||||
// so clicks on them are picked up with a delegated listener.
|
||||
// The hero actions and nav items are rendered by VitePress from the page frontmatter and
|
||||
// site config, so clicks on them are picked up with a delegated listener.
|
||||
document.addEventListener('click', (event) => {
|
||||
const link = (event.target as Element | null)?.closest?.('.VPHero .actions a');
|
||||
if (link) {
|
||||
track('home_hero_click', { text: link.textContent?.trim() });
|
||||
const target = event.target as Element | null;
|
||||
|
||||
const heroLink = target?.closest?.('.VPHero .actions a');
|
||||
if (heroLink) {
|
||||
track('home_hero_click', { text: heroLink.textContent?.trim() });
|
||||
}
|
||||
|
||||
if (target?.closest?.('.VPNav a[href="/mcp"]')) {
|
||||
track('early_access_click', { product: 'mcp' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -53,6 +53,7 @@ declare global {
|
||||
};
|
||||
Tally: {
|
||||
// From https://tally.so/help/developer-resources#2f676e40530a460ea6a634b8441f6001
|
||||
loadEmbeds: () => void;
|
||||
openPopup: (
|
||||
formId: string,
|
||||
options?: {
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Lucide MCP
|
||||
description: Join the waitlist for Lucide MCP, an MCP server that lets your coding agent search every Lucide icon and write the code for the framework you already use.
|
||||
layout: page
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import WaitlistHero from '~/.vitepress/theme/components/waitlist/WaitlistHero.vue'
|
||||
import McpDiagram from '~/.vitepress/theme/components/waitlist/McpDiagram.vue'
|
||||
</script>
|
||||
|
||||
<WaitlistHero product="mcp">
|
||||
<template #visual>
|
||||
<McpDiagram />
|
||||
</template>
|
||||
</WaitlistHero>
|
||||
+11
-3
@@ -7,7 +7,7 @@ editLink: false
|
||||
|
||||
# Privacy Statement
|
||||
|
||||
_Last updated: 27 October 2026_
|
||||
_Last updated: 10 November 2026_
|
||||
|
||||
Lucide is an open-source project. We built lucide.dev to collect as little about you as possible: We aim to collect as little personal data as possible. We don't sell your personal data, and we don't require an account to browse our website. This page explains what does happen when you visit, and why.
|
||||
|
||||
@@ -25,10 +25,16 @@ The site runs on [Vercel](https://vercel.com/legal/privacy-policy). Like any web
|
||||
|
||||
### Analytics
|
||||
|
||||
We use privacy-friendly analytics to see which pages and icons are popular, so we know what to improve. We run **OpenPanel** on our own subdomain (insights.lucide.dev), and the data it collects is stored in the EU. It counts page views and how the site is used: which icons are copied or opened in Lucide Studio, when an icon search starts, changes to the icon customiser and sorting, clicks on the home page buttons and packages, and how far down a page you scroll.
|
||||
We use privacy-friendly analytics to see which pages and icons are popular, so we know what to improve. We run **OpenPanel** on our own subdomain (insights.lucide.dev), and the data it collects is stored in the EU. It counts page views and how the site is used: which icons are copied or opened in Lucide Studio, when an icon search starts, changes to the icon customiser and sorting, clicks on the home page buttons and packages, clicks on early access menu items, waitlist sign-ups, and how far down a page you scroll.
|
||||
|
||||
OpenPanel doesn't set cookies or store anything in your browser. Along with these events it records general information such as the referring site, browser type, device type and country. Visits are counted with a temporary, anonymised identifier that cannot be used to recognise you on other sites or on later days. We don't track clicks on outgoing links, and we never combine this data with other sources.
|
||||
|
||||
### Waitlists
|
||||
|
||||
When you join a waitlist, we store your email address, the product you signed up for, the date, and any answers you give on the form or in the optional follow-up questions. We use this to send you launch updates for that product and to decide what to build. You are only added after you click the link in the confirmation email.
|
||||
|
||||
Emails and contacts are handled by [Resend](https://resend.com/legal/privacy-policy), our email provider, which acts as our processor. The optional follow-up questions are handled by [Tally](https://tally.so/help/gdpr), a Belgian form provider that stores responses in the EU and also acts as our processor. You can unsubscribe from any email. We keep your details until the waitlist closes or you ask us to delete them, whichever comes first.
|
||||
|
||||
### Search
|
||||
|
||||
Site search is provided by [Algolia DocSearch](https://www.algolia.com/policies/privacy/). When you use the search box, what you type and your IP address are sent to Algolia to return results. Your recent searches are kept in your own browser (see below).
|
||||
@@ -61,9 +67,11 @@ These items are needed for features you use, or they store choices you made your
|
||||
|
||||
We process this data on the basis of our legitimate interest (Article 6(1)(f) GDPR) in running a fast, secure and sustainable website: hosting and protecting the site, understanding how it is used, and funding the project through non-tracking ads. We keep data minimal and avoid identifying individual visitors.
|
||||
|
||||
Waitlist data is processed on the basis of your consent (Article 6(1)(a) GDPR), which you give by submitting the form and confirming your email address. You can withdraw it at any time by unsubscribing or contacting us.
|
||||
|
||||
## International transfers
|
||||
|
||||
Vercel, Algolia, Carbon Ads, GitHub and Shields.io may process data outside the European Economic Area, including in the United States. Where this happens, these providers rely on safeguards such as the EU–US Data Privacy Framework or the European Commission's Standard Contractual Clauses.
|
||||
Vercel, Algolia, Carbon Ads, GitHub, Shields.io and Resend may process data outside the European Economic Area, including in the United States. Where this happens, these providers rely on safeguards such as the EU–US Data Privacy Framework or the European Commission's Standard Contractual Clauses.
|
||||
|
||||
## Your rights
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Waitlist
|
||||
description: Confirm your place on the Lucide waitlist.
|
||||
layout: page
|
||||
head:
|
||||
- - meta
|
||||
- name: robots
|
||||
content: noindex
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import WaitlistConfirmed from '~/.vitepress/theme/components/waitlist/WaitlistConfirmed.vue'
|
||||
</script>
|
||||
|
||||
<WaitlistConfirmed />
|
||||
Reference in New Issue
Block a user