>(new Set());
+
+ const toggleExpand = (orgName: string) => {
+ setExpandedOrgs((prev) => {
+ const next = new Set(prev);
+ if (next.has(orgName)) {
+ next.delete(orgName);
+ } else {
+ next.add(orgName);
+ }
+ return next;
+ });
+ };
+
+ return (
+
+ {/* ── Your Subscriptions ── */}
+
+ Your Subscriptions
+
+ {feedSections.length === 0 && (
+
+ No recent emails from your subscriptions.
+
+ )}
+
+ {feedSections.map(({ orgName, events }) => {
+ const isExpanded = expandedOrgs.has(orgName);
+ const visibleEvents = isExpanded
+ ? events
+ : events.slice(0, MAX_VISIBLE);
+ const hiddenCount = events.length - MAX_VISIBLE;
+
+ return (
+ ({
+ thumbnailVariant: event.thumbnailVariant,
+ day: event.day,
+ month: event.month,
+ title: event.title,
+ description: toRowDescription(event),
+ bookmarked: bookmarkedIds.has(event.id),
+ onBookmark: () => onBookmark(event.id),
+ onRowClick: event.isEdgeCase
+ ? () => onEmailView(event)
+ : undefined,
+ }))}
+ onViewMore={
+ !isExpanded && events.length > MAX_VISIBLE
+ ? () => toggleExpand(orgName)
+ : undefined
+ }
+ onViewLess={isExpanded ? () => toggleExpand(orgName) : undefined}
+ />
+ );
+ })}
+
+
+ {/* ── Trending This Week ── */}
+ {trendingEvents.length > 0 && (
+
+ Trending This Week
+
+ {trendingEvents.map((event) => (
+ onBookmark(event.id),
+ onRowClick: event.isEdgeCase
+ ? () => onEmailView(event)
+ : undefined,
+ },
+ ]}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/extension/src/components/FloatingPanel.tsx b/apps/extension/src/components/FloatingPanel.tsx
new file mode 100644
index 0000000..4189ffc
--- /dev/null
+++ b/apps/extension/src/components/FloatingPanel.tsx
@@ -0,0 +1,47 @@
+import { useState } from "react";
+import App from "../App";
+import type { AppProps } from "../App";
+import FloatingIcon from "../../public/floating_icon.svg?react";
+
+export interface FloatingPanelProps extends Pick<
+ AppProps,
+ "pageContext" | "onPreviewSlot"
+> {}
+
+export default function FloatingPanel({
+ pageContext,
+ onPreviewSlot,
+}: FloatingPanelProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+ {/* Floating tab — fades out when panel opens, fades in when closed */}
+
+
+ {/* Panel outer shell — slides in/out via inline transform (reliable in shadow DOM) */}
+
+
setIsOpen(false)}
+ pageContext={pageContext}
+ onPreviewSlot={onPreviewSlot}
+ />
+
+ >
+ );
+}
diff --git a/apps/extension/src/components/OriginalEmailView.tsx b/apps/extension/src/components/OriginalEmailView.tsx
new file mode 100644
index 0000000..f3e7037
--- /dev/null
+++ b/apps/extension/src/components/OriginalEmailView.tsx
@@ -0,0 +1,100 @@
+import { Avatar } from "@app/ui";
+import type { EventItem } from "../data/types";
+
+// Figma: Inter SemiBold 20px, #5f5f5f, tracking -0.22px, leading 1.5
+const SECTION_HEADING =
+ "font-[family-name:var(--font-body)] font-semibold " +
+ "text-[1.25rem] leading-[1.5] tracking-[-0.22px] " +
+ "text-[#5f5f5f] whitespace-nowrap";
+
+// Figma: DM Sans Bold 18px, #5f5f5f, lh 28px, tracking -0.5px
+const EMAIL_TITLE =
+ "font-[family-name:var(--font-body)] font-bold " +
+ "text-[length:var(--font-size-body1)] leading-[var(--line-height-body1)] " +
+ "tracking-[var(--letter-spacing-body1)] text-[#5f5f5f]";
+
+// Figma: DM Sans Regular 14px, #5f5f5f, lh 20.2px, tracking -0.5px
+const EMAIL_BODY =
+ "font-[family-name:var(--font-body)] font-normal " +
+ "text-[length:var(--font-size-body2)] leading-[var(--line-height-body2)] " +
+ "tracking-[var(--letter-spacing-body2)] text-[#5f5f5f]";
+
+// Fallback content shown when no event is selected (shouldn't happen in normal flow)
+const FALLBACK_PARAGRAPHS = [
+ "Hello Eship,",
+ "",
+ "For Cornell builders, aspiring VCs, and startup enthusiasts: Startup Hours is being held from 7:30–9pm Thursday, on the third floor of eHub Collegetown.",
+ "",
+ "Please fill out this form for catering by Wednesday 5pm so we have a proper headcount.",
+ "",
+ "Best,",
+ "Alli",
+];
+
+export interface OriginalEmailViewProps {
+ /**
+ * The EventItem whose raw email content should be displayed.
+ * When null/undefined (shouldn't happen in normal flow) fallback placeholder is shown.
+ */
+ event?: EventItem | null;
+}
+
+export default function OriginalEmailView({ event }: OriginalEmailViewProps) {
+ const orgName = event?.orgName ?? "Cornell DTI";
+ const emailTitle = event?.rawEmailTitle ?? event?.title ?? "Original Email";
+ const paragraphs = event?.rawEmailParagraphs ?? FALLBACK_PARAGRAPHS;
+
+ return (
+
+ {/* "Original Email" heading — Inter SemiBold 20px */}
+
Original Email
+
+ {/* Email card — Figma: white bg, #ececec 1.5px border, rounded-[12px], p-[16px], gap-[20px] */}
+
+ {/* Org header — avatar circle + org name */}
+
+
+ {/* Email content */}
+
+ {/* Subject / title — DM Sans Bold 18px */}
+
+ {emailTitle}
+
+
+ {/* Body paragraphs — empty strings become blank-line spacers */}
+
+ {paragraphs.map((line, i) =>
+ line === "" ? (
+
+ ) : (
+
+ {line}
+
+ ),
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/extension/src/components/SearchHeader.tsx b/apps/extension/src/components/SearchHeader.tsx
new file mode 100644
index 0000000..628b437
--- /dev/null
+++ b/apps/extension/src/components/SearchHeader.tsx
@@ -0,0 +1,185 @@
+/**
+ * SearchHeader — Extension panel header
+ *
+ * Two variants matching the Figma designs:
+ *
+ * 'main' (node 528:4032) — default view header
+ * Logo + Close | SearchBar | Toggle (Feed / Bookmarks)
+ *
+ * 'search' (node 554:7828) — active search / original-email view header
+ * Logo + Close | [← back] SearchBar (no toggle)
+ */
+
+import type { ComponentPropsWithoutRef } from "react";
+import { SearchBar, Toggle, LoopLogo } from "@app/ui";
+import CloseIcon from "@app/ui/assets/close_search.svg?react";
+import ChevronBackIcon from "@app/ui/assets/chevron-back.svg?react";
+
+// ── Public types ───────────────────────────────────────────────────────────
+
+export type SearchHeaderVariant = "main" | "search";
+
+export interface SearchHeaderProps extends Omit<
+ ComponentPropsWithoutRef<"div">,
+ "onChange"
+> {
+ /**
+ * Layout variant:
+ * 'main' → SearchBar + Feed/Bookmarks Toggle below logo row
+ * 'search' → Back chevron + SearchBar below logo row (no toggle)
+ * Defaults to 'main'.
+ */
+ variant?: SearchHeaderVariant;
+
+ /** Called when the × close button is clicked. */
+ onClose?: () => void;
+
+ /** Controlled search query string. */
+ searchQuery?: string;
+ /** Called with the new value whenever the search input changes. */
+ onSearchChange?: (value: string) => void;
+ /** Called when the × clear button inside the search bar is clicked. */
+ onSearchClear?: () => void;
+ /** Called when the search input receives focus (used to switch to search view). */
+ onSearchFocus?: () => void;
+
+ /**
+ * Called when the ‹ back chevron is clicked.
+ * Only relevant in the 'search' variant.
+ */
+ onBack?: () => void;
+
+ /**
+ * Currently active toggle tab value ('feed' | 'bookmarks').
+ * Only relevant in the 'main' variant. Defaults to 'feed'.
+ */
+ activeTab?: string;
+ /**
+ * Called with the new tab value when a toggle option is selected.
+ * Only relevant in the 'main' variant.
+ */
+ onTabChange?: (tab: string) => void;
+}
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+const TOGGLE_OPTIONS = [
+ { value: "feed", label: "Feed" },
+ { value: "bookmarks", label: "Bookmarks" },
+];
+
+// Shared icon-button wrapper — 25.6 px hit target, subtle hover bg
+const ICON_BTN =
+ "shrink-0 flex items-center justify-center " +
+ "size-[var(--space-6)] rounded-[var(--radius-input)] cursor-pointer " +
+ "hover:bg-[var(--color-surface-subtle)] transition-colors duration-150 " +
+ "focus-visible:outline-2 focus-visible:outline-offset-1 " +
+ "focus-visible:outline-[var(--color-primary-700)]";
+
+// ── Component ──────────────────────────────────────────────────────────────
+
+export function SearchHeader({
+ variant = "main",
+ onClose,
+ searchQuery = "",
+ onSearchChange,
+ onSearchClear,
+ onSearchFocus,
+ onBack,
+ activeTab = "feed",
+ onTabChange,
+ className,
+ ...rest
+}: SearchHeaderProps) {
+ return (
+
+ {/* ── Logo row: wordmark left, close right ── */}
+
+ {/* size="sm": 24px mark + 32px "Loop" wordmark — matches Figma panel header */}
+
+
+
+
+
+ {/* ── Search row ── */}
+ {variant === "main" ? (
+ /* Main variant: search bar spans full width */
+
+ ) : (
+ /* Search variant: back chevron + search bar (flex-1).
+ Figma gap between chevron and input: 4px (--space-1) */
+
+
+
+
+
+ )}
+
+ {/* ── Toggle (main variant only) ── */}
+ {variant === "main" && (
+
{})}
+ size="compact"
+ className="w-full"
+ />
+ )}
+
+ );
+}
+
+export default SearchHeader;
diff --git a/apps/extension/src/components/SearchView.tsx b/apps/extension/src/components/SearchView.tsx
new file mode 100644
index 0000000..95cb484
--- /dev/null
+++ b/apps/extension/src/components/SearchView.tsx
@@ -0,0 +1,229 @@
+/**
+ * SearchView — empty state (popular searches) + results state.
+ *
+ * Empty state (no query):
+ * • Clicking a popular search row calls onSearchSelect(term) → populates the
+ * search bar in App.tsx via handleSearchSelect.
+ *
+ * Results state (query present):
+ * • Filters MOCK_EVENTS using useSearchResults(query).
+ * • "Sort by" tag strip filters results further (OR match).
+ * • Tag strip supports +, pencil edit mode, and × delete via SortByTags.
+ * • Each BookmarkCard wires RSVP / Add to Calendar / bookmark actions.
+ */
+
+import { useState } from "react";
+import type { EventItem } from "../data/types";
+import { useSearchResults } from "../data/useEvents";
+import {
+ getPrimaryLink,
+ getLinkLabel,
+ openExternalUrl,
+} from "../utils/linkUtils";
+import { buildGCalUrl } from "../utils/calendarUtils";
+import { BookmarkCard } from "./BookmarkCard";
+import { SortByTags } from "./SortByTags";
+
+// ── Shared typography ──────────────────────────────────────────────────────
+
+// Figma: Inter Regular 16px, #5f5f5f, tracking -0.176px, leading 1.5
+const UI_BODY =
+ "font-[family-name:var(--font-body)] font-normal " +
+ "text-[1rem] leading-[1.5] tracking-[-0.176px] text-[#5f5f5f]";
+
+const SORT_LABEL = UI_BODY + " whitespace-nowrap";
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+const POPULAR_SEARCHES = [
+ { rank: "#1", term: "Recruitment" },
+ { rank: "#2", term: "Sports" },
+ { rank: "#3", term: "Concert" },
+ { rank: "#4", term: "Housing" },
+ { rank: "#5", term: "A&S" },
+];
+
+const INITIAL_SORT_TAGS = [
+ "Internships",
+ "Early career",
+ "Tech",
+ "Mentorship",
+ "Just for fun",
+];
+
+// ── SearchEmptyState ───────────────────────────────────────────────────────
+
+interface SearchEmptyStateProps {
+ onSelect: (term: string) => void;
+}
+
+function SearchEmptyState({ onSelect }: SearchEmptyStateProps) {
+ return (
+
+ {/* Heading — Figma: DM Sans Medium 18px, #5f5f5f, lh 28px, tracking -0.5px */}
+
+ Popular searches this week
+
+
+ {/* Ranked rows */}
+
+ {POPULAR_SEARCHES.map(({ rank, term }) => (
+
+ ))}
+
+
+ );
+}
+
+// ── SearchResultsState ─────────────────────────────────────────────────────
+
+interface SearchResultsStateProps {
+ query: string;
+ bookmarkedIds: Set;
+ onBookmark: (id: string) => void;
+ onEmailView: (event: EventItem) => void;
+}
+
+function SearchResultsState({
+ query,
+ bookmarkedIds,
+ onBookmark,
+ onEmailView,
+}: SearchResultsStateProps) {
+ const results = useSearchResults(query);
+
+ const [availableTags, setAvailableTags] = useState(INITIAL_SORT_TAGS);
+ const [activeTags, setActiveTags] = useState([]);
+
+ const handleTagToggle = (tag: string) =>
+ setActiveTags((prev) =>
+ prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
+ );
+
+ const handleTagAdd = (tag: string) => {
+ setAvailableTags((prev) => [...prev, tag]);
+ setActiveTags((prev) => [...prev, tag]);
+ };
+
+ const handleTagRemove = (tag: string) => {
+ setAvailableTags((prev) => prev.filter((t) => t !== tag));
+ setActiveTags((prev) => prev.filter((t) => t !== tag));
+ };
+
+ const filtered =
+ activeTags.length === 0
+ ? results
+ : results.filter((e) => activeTags.some((tag) => e.tags.includes(tag)));
+
+ return (
+
+ {/* Sort by */}
+
+
+ {/* Result cards */}
+
+ {filtered.length === 0 && (
+
+ No results found.
+
+ )}
+
+ {filtered.map((event) => {
+ const primaryLink = getPrimaryLink(event);
+ const primaryAction = primaryLink
+ ? {
+ label: getLinkLabel(primaryLink),
+ onClick: () => openExternalUrl(primaryLink.url),
+ }
+ : undefined;
+
+ const onAddToCalendar = event.calendarEvent
+ ? () => openExternalUrl(buildGCalUrl(event.calendarEvent!))
+ : undefined;
+
+ return (
+
onEmailView(event) : undefined
+ }
+ tags={event.tags}
+ primaryAction={primaryAction}
+ onAddToCalendar={onAddToCalendar}
+ onUnbookmark={
+ bookmarkedIds.has(event.id)
+ ? () => onBookmark(event.id)
+ : undefined
+ }
+ />
+ );
+ })}
+
+
+ );
+}
+
+// ── SearchView ─────────────────────────────────────────────────────────────
+
+export interface SearchViewProps {
+ query?: string;
+ onSearchSelect?: (term: string) => void;
+ bookmarkedIds?: Set;
+ onBookmark?: (id: string) => void;
+ onEmailView?: (event: EventItem) => void;
+}
+
+export default function SearchView({
+ query = "",
+ onSearchSelect,
+ bookmarkedIds = new Set(),
+ onBookmark = () => {},
+ onEmailView = () => {},
+}: SearchViewProps) {
+ return query.trim() === "" ? (
+ {})} />
+ ) : (
+
+ );
+}
diff --git a/apps/extension/src/components/SortByTags.tsx b/apps/extension/src/components/SortByTags.tsx
new file mode 100644
index 0000000..7f8f1a4
--- /dev/null
+++ b/apps/extension/src/components/SortByTags.tsx
@@ -0,0 +1,123 @@
+/**
+ * SortByTags — reusable sort/filter tag strip.
+ *
+ * Used in BookmarkView and SearchView. Manages:
+ * • Active tag toggling (click to filter; active tags use primary/orange bg)
+ * • Custom tag addition via a "+" inline input (press Enter or blur to add)
+ * • Edit mode via pencil icon: Tag's built-in onDismiss renders the design-
+ * system × button; click × to delete the tag
+ */
+
+import { useRef, useState } from "react";
+import { Tag } from "@app/ui";
+import EditIcon from "@app/ui/assets/edit_icon.svg?react";
+
+export interface SortByTagsProps {
+ tags: string[];
+ activeTags: string[];
+ onTagToggle: (tag: string) => void;
+ onTagAdd: (tag: string) => void;
+ onTagRemove: (tag: string) => void;
+}
+
+export function SortByTags({
+ tags,
+ activeTags,
+ onTagToggle,
+ onTagAdd,
+ onTagRemove,
+}: SortByTagsProps) {
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [showInput, setShowInput] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const inputRef = useRef(null);
+
+ const commitNewTag = () => {
+ const trimmed = inputValue.trim();
+ if (trimmed && !tags.includes(trimmed)) {
+ onTagAdd(trimmed);
+ }
+ setInputValue("");
+ setShowInput(false);
+ };
+
+ const handlePlusClick = () => {
+ setShowInput(true);
+ setTimeout(() => inputRef.current?.focus(), 0);
+ };
+
+ return (
+
+ {tags.map((label) => {
+ const isActive = activeTags.includes(label);
+
+ return (
+ onTagToggle(label) : undefined}
+ // In edit mode: show the design-system × dismiss button
+ onDismiss={isEditMode ? () => onTagRemove(label) : undefined}
+ dismissLabel={`Remove ${label}`}
+ aria-pressed={!isEditMode ? isActive : undefined}
+ >
+ {label}
+
+ );
+ })}
+
+ {/* "+" add new tag — hidden while editing */}
+ {!isEditMode && !showInput && (
+
+ +
+
+ )}
+
+ {/* Inline custom tag input */}
+ {showInput && (
+ setInputValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") commitNewTag();
+ if (e.key === "Escape") {
+ setInputValue("");
+ setShowInput(false);
+ }
+ }}
+ onBlur={commitNewTag}
+ placeholder="New tag…"
+ className={[
+ "h-[var(--space-6)] w-[90px] rounded-[var(--radius-button)]",
+ "border border-[var(--color-primary-700)] bg-white",
+ "px-[var(--space-2)] text-[length:var(--font-size-body3)]",
+ "font-[family-name:var(--font-body)] font-normal",
+ "text-[var(--color-neutral-900)] outline-none",
+ ].join(" ")}
+ />
+ )}
+
+ {/* Pencil / edit-mode toggle */}
+
+
+ );
+}
diff --git a/apps/extension/src/content.css b/apps/extension/src/content.css
new file mode 100644
index 0000000..de5650b
--- /dev/null
+++ b/apps/extension/src/content.css
@@ -0,0 +1,19 @@
+@import "tailwindcss";
+
+/* Explicitly scan component files so Tailwind generates all used utility classes */
+@source "../src/**/*.{ts,tsx}";
+@source "../../../shared/ui/src/**/*.{ts,tsx}";
+
+@import "../../../shared/ui/src/styles/tokens.css";
+
+/*
+ * Tailwind v4 `.border` uses `border-style: var(--tw-border-style)`.
+ * `@property --tw-border-style` does not apply inside ShadowRoot adoptedStyleSheets,
+ * so `--tw-border-style` is invalid and borders disappear (see tailwindcss#16025).
+ * Force solid borders after Tailwind utilities so width + color utilities still work.
+ */
+@layer utilities {
+ .border {
+ border-style: solid;
+ }
+}
diff --git a/apps/extension/src/content.tsx b/apps/extension/src/content.tsx
new file mode 100644
index 0000000..15b4474
--- /dev/null
+++ b/apps/extension/src/content.tsx
@@ -0,0 +1,92 @@
+import { ConvexAuthProvider } from "@convex-dev/auth/react";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { ConvexReactClient } from "convex/react";
+import FloatingPanel from "./components/FloatingPanel.tsx";
+import contentStyles from "./content.css?inline";
+import { showSlotPreview, removeSlotPreview } from "./gcalHighlight";
+import type { EventItem } from "./data/types";
+import type { PageContext } from "./App";
+
+const convexUrl = import.meta.env.VITE_CONVEX_URL as string | undefined;
+
+function loadFonts() {
+ if (document.getElementById("cornell-loop-fonts")) return;
+ const link = document.createElement("link");
+ link.id = "cornell-loop-fonts";
+ link.rel = "stylesheet";
+ link.href =
+ "https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Inter:wght@400;500;600;700&family=Manrope:wght@600;700&display=swap";
+ document.head.appendChild(link);
+}
+
+function mount() {
+ if (!convexUrl) {
+ console.warn(
+ "[Cornell Loop] VITE_CONVEX_URL is not set. " +
+ "Create apps/extension/.env.local with VITE_CONVEX_URL= and rebuild.",
+ );
+ return;
+ }
+
+ if (document.getElementById("cornell-loop-host")) return;
+
+ const convex = new ConvexReactClient(convexUrl);
+
+ // Detect which Google product the content script is running in.
+ const pageContext: PageContext = window.location.hostname.includes(
+ "calendar.google.com",
+ )
+ ? "gcal"
+ : "gmail";
+
+ loadFonts();
+
+ const host = document.createElement("div");
+ host.id = "cornell-loop-host";
+ // Explicit styles prevent Gmail/Calendar from accidentally hiding or
+ // reflowing the host. Fixed + zero-size keeps it out of the page layout
+ // while still allowing the shadow DOM children to use fixed positioning.
+ host.style.cssText =
+ "position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;overflow:visible;pointer-events:none;";
+ document.body.appendChild(host);
+
+ const shadow = host.attachShadow({ mode: "open" });
+
+ // Use a