From 4b00ceee1d04a7361f1349b58d3047a3b4d1a885 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sun, 12 Apr 2026 18:30:31 -0400 Subject: [PATCH 1/3] Smooth side panel transition using CSS grid animation Replace flex layout with CSS grid and animate gridTemplateColumns so the main content card resizes smoothly when navigating between pages with and without a side panel. Avoids Framer Motion layout prop which distorts inner children via scale transforms. - Convert parent flex container to grid with spring-animated columns - Clone removed portal content into ghost div for exit animation - Add useMediaQuery hook to gate panel column on xl breakpoint --- .../components/layouts/dashboard-layout.tsx | 21 ++++++++-- .../layouts/dashboard-side-panel.tsx | 40 +++++++++++++++---- apps/dashboard/src/lib/use-media-query.ts | 15 +++++++ 3 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 apps/dashboard/src/lib/use-media-query.ts diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index d1acc52..dd0cacf 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { getRouteApi, Outlet } from "@tanstack/react-router"; +import { motion } from "motion/react"; import { lazy, Suspense } from "react"; import { githubMyIssuesQueryOptions, @@ -7,9 +8,11 @@ import { } from "#/lib/github.query"; import { useGitHubRevalidation } from "#/lib/use-github-revalidation"; import { useHasMounted } from "#/lib/use-has-mounted"; +import { useMediaQuery } from "#/lib/use-media-query"; import { DashboardBottomBar } from "./dashboard-bottombar"; import { DashboardMobileNav } from "./dashboard-mobile-nav"; import { + SIDE_PANEL_WIDTH, SidePanelProvider, SidePanelSlot, SidePanelToggle, @@ -61,6 +64,8 @@ export function DashboardLayout() { const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data); const sidePanel = useSidePanelSlot(); + const isXl = useMediaQuery("(min-width: 1280px)"); + const showPanel = isXl && sidePanel.hasContent && !sidePanel.collapsed; return (
@@ -83,19 +88,29 @@ export function DashboardLayout() { toggle: sidePanel.toggle, }} > -
-
+ +
+ -
+ (null); + const ghostRef = useRef(null); + const exitTimer = useRef | null>(null); const refCallback = useCallback( (el: HTMLDivElement | null) => { @@ -86,31 +91,52 @@ export function SidePanelSlot({ }; check(); - const observer = new MutationObserver(check); + const observer = new MutationObserver((mutations) => { + const has = el.childNodes.length > 0; + + if (!has && ghostRef.current) { + // Content removed — clone into ghost for exit animation + ghostRef.current.innerHTML = ""; + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + ghostRef.current.appendChild(node.cloneNode(true)); + } + } + if (exitTimer.current) clearTimeout(exitTimer.current); + exitTimer.current = setTimeout(() => { + if (ghostRef.current) ghostRef.current.innerHTML = ""; + }, 500); + } else if (has && ghostRef.current) { + // Content added — clear ghost + ghostRef.current.innerHTML = ""; + if (exitTimer.current) clearTimeout(exitTimer.current); + } + + setHasChildren(has); + onHasContent(has); + }); observer.observe(el, { childList: true }); el.addEventListener("sidepanel-content", check); return () => { observer.disconnect(); el.removeEventListener("sidepanel-content", check); + if (exitTimer.current) clearTimeout(exitTimer.current); }; }, [onHasContent]); const show = hasChildren && !collapsed; return ( - +
+
- +
); } diff --git a/apps/dashboard/src/lib/use-media-query.ts b/apps/dashboard/src/lib/use-media-query.ts new file mode 100644 index 0000000..64d49e3 --- /dev/null +++ b/apps/dashboard/src/lib/use-media-query.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export function useMediaQuery(query: string) { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const mq = window.matchMedia(query); + setMatches(mq.matches); + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [query]); + + return matches; +} From 4eb32e602dcd2df4984d12f09a521f9f541fdd98 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sun, 12 Apr 2026 19:31:43 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20launch=20prep=20=E2=80=94=20alpha?= =?UTF-8?q?=20notice,=20extension=20prompt,=20legal=20pages,=20redirect=20?= =?UTF-8?q?extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README alpha warning; one-time alpha welcome modal on dashboard load - Extension install chip in bottom bar with 30-day dismiss cooldown and optional hide when browser extension marks document (dashboard-presence.js) - Public /privacy and /terms with shared layout; @tailwindcss/typography for prose - siteConfig.browserExtensionInstallUrl; logo-128.png for store assets - Extension: repo overview + profile redirect rules; v0.1.1; diff-kit.com presence script --- README.md | 3 + apps/dashboard/public/logo-128.png | Bin 0 -> 26536 bytes .../layouts/alpha-notice-dialog.tsx | 90 ++++++++++++++++ .../layouts/dashboard-bottombar.tsx | 6 +- .../components/layouts/dashboard-layout.tsx | 6 ++ .../layouts/extension-install-prompt.tsx | 89 ++++++++++++++++ .../legal/legal-document-layout.tsx | 60 +++++++++++ .../src/lib/diffkit-extension-detect.ts | 11 ++ .../lib/extension-install-prompt-storage.ts | 55 ++++++++++ apps/dashboard/src/lib/site-config.ts | 4 + apps/dashboard/src/routeTree.gen.ts | 42 ++++++++ apps/dashboard/src/routes/privacy.tsx | 100 ++++++++++++++++++ apps/dashboard/src/routes/terms.tsx | 100 ++++++++++++++++++ extensions/diffkit-redirect/README.md | 5 + .../diffkit-redirect/dashboard-presence.js | 12 +++ extensions/diffkit-redirect/manifest.json | 18 +++- extensions/diffkit-redirect/shared.js | 32 ++++++ packages/ui/package.json | 1 + packages/ui/src/styles/globals.css | 1 + pnpm-lock.yaml | 34 ++++++ 20 files changed, 664 insertions(+), 5 deletions(-) create mode 100644 apps/dashboard/public/logo-128.png create mode 100644 apps/dashboard/src/components/layouts/alpha-notice-dialog.tsx create mode 100644 apps/dashboard/src/components/layouts/extension-install-prompt.tsx create mode 100644 apps/dashboard/src/components/legal/legal-document-layout.tsx create mode 100644 apps/dashboard/src/lib/diffkit-extension-detect.ts create mode 100644 apps/dashboard/src/lib/extension-install-prompt-storage.ts create mode 100644 apps/dashboard/src/routes/privacy.tsx create mode 100644 apps/dashboard/src/routes/terms.tsx create mode 100644 extensions/diffkit-redirect/dashboard-presence.js diff --git a/README.md b/README.md index 434c558..ad3a267 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of their pull requests, issues, and code reviews — without the noise. +> [!WARNING] +> **Alpha** — DiffKit is in early release. Expect bugs, errors, and rough edges. Feedback and issue reports are welcome on [GitHub Issues](https://github.com/stylessh/diffkit/issues). + ## Features - **Pull Requests** — View, filter, and manage your open PRs across repos diff --git a/apps/dashboard/public/logo-128.png b/apps/dashboard/public/logo-128.png new file mode 100644 index 0000000000000000000000000000000000000000..0edb2b829be5cfc232ba09fc7f66deb351146994 GIT binary patch literal 26536 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 v*Ef0`tD`?wI_ { + if (!hasMounted) { + return; + } + try { + if (!localStorage.getItem(STORAGE_KEY)) { + setOpen(true); + } + } catch { + // Storage blocked — skip modal + } + }, [hasMounted]); + + function dismiss() { + try { + localStorage.setItem(STORAGE_KEY, "1"); + } catch { + // ignore + } + setOpen(false); + } + + return ( + !next && dismiss()}> + + + + Welcome to {siteConfig.name} + + + Thanks for trying the dashboard — here is what you should know + before you dive in. + + + +
+ + + Alpha software + + + DiffKit is in early release. Expect bugs, rough edges, and + unfinished flows. When something breaks, please report it on our + GitHub issues board — it helps us ship a stable product. + + +
+ + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/layouts/dashboard-bottombar.tsx b/apps/dashboard/src/components/layouts/dashboard-bottombar.tsx index c7ff3de..af9435d 100644 --- a/apps/dashboard/src/components/layouts/dashboard-bottombar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-bottombar.tsx @@ -3,15 +3,15 @@ import { cn } from "@diffkit/ui/lib/utils"; import { useShowOrgSetupQueryState } from "#/lib/github-access-dialog-query"; import { openGitHubAccessPrompt } from "#/lib/github-access-modal-store"; import { removeWarning, useWarnings } from "#/lib/warning-store"; +import { ExtensionInstallPrompt } from "./extension-install-prompt"; export function DashboardBottomBar() { const warnings = useWarnings(); const [, setShowOrgSetup] = useShowOrgSetupQueryState(); - if (warnings.length === 0) return null; - return ( -
+
+ {warnings.map((warning) => (
default: mod.GitHubAccessDialog, })), ); +const AlphaNoticeDialog = lazy(() => + import("./alpha-notice-dialog").then((mod) => ({ + default: mod.AlphaNoticeDialog, + })), +); const routeApi = getRouteApi("/_protected"); @@ -126,6 +131,7 @@ export function DashboardLayout() { /> +
diff --git a/apps/dashboard/src/components/layouts/extension-install-prompt.tsx b/apps/dashboard/src/components/layouts/extension-install-prompt.tsx new file mode 100644 index 0000000..aa03663 --- /dev/null +++ b/apps/dashboard/src/components/layouts/extension-install-prompt.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { XIcon } from "@diffkit/icons"; +import { Logo } from "@diffkit/ui/components/logo"; +import { useEffect, useState } from "react"; +import { isDiffKitExtensionPresent } from "#/lib/diffkit-extension-detect"; +import { + recordExtensionInstallPromptDismissed, + shouldShowExtensionInstallPrompt, +} from "#/lib/extension-install-prompt-storage"; +import { siteConfig } from "#/lib/site-config"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +export function ExtensionInstallPrompt() { + const hasMounted = useHasMounted(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!hasMounted) { + return; + } + + function applyVisibility() { + setVisible(shouldShowExtensionInstallPrompt(isDiffKitExtensionPresent())); + } + + applyVisibility(); + + if (isDiffKitExtensionPresent()) { + return; + } + + const el = document.documentElement; + const observer = new MutationObserver(() => { + if (isDiffKitExtensionPresent()) { + setVisible(false); + observer.disconnect(); + } else { + applyVisibility(); + } + }); + observer.observe(el, { + attributes: true, + attributeFilter: ["data-diffkit-extension"], + }); + return () => observer.disconnect(); + }, [hasMounted]); + + function dismiss() { + recordExtensionInstallPromptDismissed(); + setVisible(false); + } + + if (!visible) { + return null; + } + + const installHref = siteConfig.browserExtensionInstallUrl; + + return ( +
+ + + Install the DiffKit extension to redirect GitHub PRs, issues, and + matching pages here. + + + Install + + +
+ ); +} diff --git a/apps/dashboard/src/components/legal/legal-document-layout.tsx b/apps/dashboard/src/components/legal/legal-document-layout.tsx new file mode 100644 index 0000000..f3902ce --- /dev/null +++ b/apps/dashboard/src/components/legal/legal-document-layout.tsx @@ -0,0 +1,60 @@ +import { Logo } from "@diffkit/ui/components/logo"; +import { cn } from "@diffkit/ui/lib/utils"; +import { Link } from "@tanstack/react-router"; +import { siteConfig } from "#/lib/site-config"; + +type LegalDocumentLayoutProps = { + title: string; + children: React.ReactNode; +}; + +export function LegalDocumentLayout({ + title, + children, +}: LegalDocumentLayoutProps) { + return ( +
+
+
+ + + + {siteConfig.name} + + +

+ {title} +

+

+ + ← Back to home + +

+
+ +
p:first-of-type]:text-[15px] [&>p:first-of-type]:leading-[1.75] [&>p:first-of-type]:text-foreground/90", + "prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-foreground", + "prose-h2:mt-12 prose-h2:mb-4 prose-h2:border-b prose-h2:border-border/60 prose-h2:pb-3 prose-h2:text-xl prose-h2:leading-snug", + "prose-ul:my-6 prose-ul:space-y-3 prose-li:my-0 prose-li:text-[15px] prose-li:leading-[1.75] prose-li:text-muted-foreground", + "prose-a:font-medium prose-a:text-primary prose-a:no-underline prose-a:underline-offset-4 hover:prose-a:underline", + )} + > + {children} +
+
+
+ ); +} diff --git a/apps/dashboard/src/lib/diffkit-extension-detect.ts b/apps/dashboard/src/lib/diffkit-extension-detect.ts new file mode 100644 index 0000000..c73d354 --- /dev/null +++ b/apps/dashboard/src/lib/diffkit-extension-detect.ts @@ -0,0 +1,11 @@ +/** + * The DiffKit browser extension sets `data-diffkit-extension="1"` on `` via + * `extensions/diffkit-redirect/dashboard-presence.js` (content script on the + * dashboard origin). Page JS cannot read the extension isolated world; DOM only. + */ +export function isDiffKitExtensionPresent(): boolean { + if (typeof document === "undefined") { + return false; + } + return document.documentElement.dataset.diffkitExtension === "1"; +} diff --git a/apps/dashboard/src/lib/extension-install-prompt-storage.ts b/apps/dashboard/src/lib/extension-install-prompt-storage.ts new file mode 100644 index 0000000..2f86908 --- /dev/null +++ b/apps/dashboard/src/lib/extension-install-prompt-storage.ts @@ -0,0 +1,55 @@ +/** Hide the install chip for 30 days after dismiss; then show again if extension still missing. */ +const COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; + +const STORAGE_KEY_AT = "diffkit-extension-prompt-dismissed-at"; +/** Legacy boolean dismiss — migrated on read */ +const LEGACY_STORAGE_KEY = "diffkit-extension-prompt-dismissed"; + +function readDismissedAtMs(): number | null { + try { + const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacy === "1") { + localStorage.removeItem(LEGACY_STORAGE_KEY); + return null; + } + + const raw = localStorage.getItem(STORAGE_KEY_AT); + if (!raw) { + return null; + } + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + return null; + } + return n; + } catch { + return null; + } +} + +/** + * Whether to show the “install extension” prompt. + * When extension is present, never show. + * When dismissed, hide until 30 days after that timestamp. + */ +export function shouldShowExtensionInstallPrompt( + isExtensionPresent: boolean, +): boolean { + if (isExtensionPresent) { + return false; + } + const dismissedAt = readDismissedAtMs(); + if (dismissedAt === null) { + return true; + } + return Date.now() - dismissedAt >= COOLDOWN_MS; +} + +export function recordExtensionInstallPromptDismissed(): void { + try { + localStorage.setItem(STORAGE_KEY_AT, String(Date.now())); + localStorage.removeItem(LEGACY_STORAGE_KEY); + } catch { + // ignore + } +} diff --git a/apps/dashboard/src/lib/site-config.ts b/apps/dashboard/src/lib/site-config.ts index f03f014..cf241ea 100644 --- a/apps/dashboard/src/lib/site-config.ts +++ b/apps/dashboard/src/lib/site-config.ts @@ -2,6 +2,8 @@ type SiteConfig = { name: string; domain: string; url: string; + /** Where users install the GitHub → DiffKit redirect browser extension (store or docs). */ + browserExtensionInstallUrl: string; githubRepositoryUrl: string; themeColor: string; socialImagePath: string; @@ -15,6 +17,8 @@ export const siteConfig: SiteConfig = { name: "DiffKit", domain: "diff-kit.com", url: "https://diff-kit.com", + browserExtensionInstallUrl: + "https://github.com/stylessh/diffkit/blob/main/extensions/diffkit-redirect/README.md#install-locally", githubRepositoryUrl: "https://github.com/stylessh/diffkit", themeColor: "#00C943", socialImagePath: "/logo512.png", diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 1814f1c..de249ee 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -9,7 +9,9 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as TermsRouteImport } from './routes/terms' import { Route as SetupRouteImport } from './routes/setup' +import { Route as PrivacyRouteImport } from './routes/privacy' import { Route as LoginRouteImport } from './routes/login' import { Route as ProtectedRouteImport } from './routes/_protected' import { Route as ProtectedIndexRouteImport } from './routes/_protected/index' @@ -29,11 +31,21 @@ import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_pr import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' +const TermsRoute = TermsRouteImport.update({ + id: '/terms', + path: '/terms', + getParentRoute: () => rootRouteImport, +} as any) const SetupRoute = SetupRouteImport.update({ id: '/setup', path: '/setup', getParentRoute: () => rootRouteImport, } as any) +const PrivacyRoute = PrivacyRouteImport.update({ + id: '/privacy', + path: '/privacy', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -131,7 +143,9 @@ const ProtectedOwnerRepoIssuesIssueIdRoute = export interface FileRoutesByFullPath { '/': typeof ProtectedIndexRoute '/login': typeof LoginRoute + '/privacy': typeof PrivacyRoute '/setup': typeof SetupRoute + '/terms': typeof TermsRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute '/reviews': typeof ProtectedReviewsRoute @@ -150,7 +164,9 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/login': typeof LoginRoute + '/privacy': typeof PrivacyRoute '/setup': typeof SetupRoute + '/terms': typeof TermsRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute '/reviews': typeof ProtectedReviewsRoute @@ -171,7 +187,9 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_protected': typeof ProtectedRouteWithChildren '/login': typeof LoginRoute + '/privacy': typeof PrivacyRoute '/setup': typeof SetupRoute + '/terms': typeof TermsRoute '/_protected/issues': typeof ProtectedIssuesRoute '/_protected/pulls': typeof ProtectedPullsRoute '/_protected/reviews': typeof ProtectedReviewsRoute @@ -194,7 +212,9 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' + | '/privacy' | '/setup' + | '/terms' | '/issues' | '/pulls' | '/reviews' @@ -213,7 +233,9 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/login' + | '/privacy' | '/setup' + | '/terms' | '/issues' | '/pulls' | '/reviews' @@ -233,7 +255,9 @@ export interface FileRouteTypes { | '__root__' | '/_protected' | '/login' + | '/privacy' | '/setup' + | '/terms' | '/_protected/issues' | '/_protected/pulls' | '/_protected/reviews' @@ -255,7 +279,9 @@ export interface FileRouteTypes { export interface RootRouteChildren { ProtectedRoute: typeof ProtectedRouteWithChildren LoginRoute: typeof LoginRoute + PrivacyRoute: typeof PrivacyRoute SetupRoute: typeof SetupRoute + TermsRoute: typeof TermsRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiWebhooksGithubRoute: typeof ApiWebhooksGithubRoute ApiGithubAppAuthorizeRoute: typeof ApiGithubAppAuthorizeRoute @@ -264,6 +290,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/terms': { + id: '/terms' + path: '/terms' + fullPath: '/terms' + preLoaderRoute: typeof TermsRouteImport + parentRoute: typeof rootRouteImport + } '/setup': { id: '/setup' path: '/setup' @@ -271,6 +304,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SetupRouteImport parentRoute: typeof rootRouteImport } + '/privacy': { + id: '/privacy' + path: '/privacy' + fullPath: '/privacy' + preLoaderRoute: typeof PrivacyRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -446,7 +486,9 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ProtectedRoute: ProtectedRouteWithChildren, LoginRoute: LoginRoute, + PrivacyRoute: PrivacyRoute, SetupRoute: SetupRoute, + TermsRoute: TermsRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiWebhooksGithubRoute: ApiWebhooksGithubRoute, ApiGithubAppAuthorizeRoute: ApiGithubAppAuthorizeRoute, diff --git a/apps/dashboard/src/routes/privacy.tsx b/apps/dashboard/src/routes/privacy.tsx new file mode 100644 index 0000000..8cbb2a4 --- /dev/null +++ b/apps/dashboard/src/routes/privacy.tsx @@ -0,0 +1,100 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { LegalDocumentLayout } from "#/components/legal/legal-document-layout"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { siteConfig } from "#/lib/site-config"; + +const issuesUrl = `${siteConfig.githubRepositoryUrl}/issues`; + +export const Route = createFileRoute("/privacy")({ + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Privacy Policy"), + description: `How ${siteConfig.name} handles GitHub account data, sessions, and your information.`, + robots: "index", + }), + component: PrivacyPage, +}); + +function PrivacyPage() { + return ( + +

+ This policy describes what {siteConfig.name} (“we”, “us”) collects and + how we use it when you use our web app and related services. We process + data only as needed to provide the product and to keep your account + secure. +

+ +

What we collect

+

+ When you sign in with GitHub, we receive basic profile information that + GitHub makes available to the OAuth application (for example your GitHub + username and, where applicable, your email address). We also create and + store session and account records so you can stay signed in and use the + service. +

+

+ To show pull requests, issues, reviews, and repository context, we fetch + data from GitHub’s APIs on your behalf. We may cache or store limited + metadata and content needed to make the dashboard fast and reliable (for + example identifiers, titles, state, timestamps, and similar fields). +

+ +

Where your actions happen

+

+ Actions that change data on GitHub—such as posting comments, submitting + reviews, merging, or editing resources—are performed through GitHub’s + platform. {siteConfig.name} does not replace GitHub’s own terms or + privacy commitments for how GitHub processes data when you use{" "} + + github.com + {" "} + or GitHub’s APIs. +

+ +

How we use data

+
    +
  • Operating authentication, sessions, and security.
  • +
  • + Displaying and syncing GitHub information you choose to access in the + product. +
  • +
  • + Improving reliability and fixing errors (including limited logs). +
  • +
+ +

Sharing

+

+ We do not sell your personal information. We use infrastructure + providers (such as hosting and database services) to run the + application; they process data only to provide the service. +

+ +

Retention and deletion

+

+ We retain data for as long as your account is active and as needed for + the purposes above. You can disconnect access from GitHub’s side + according to GitHub’s settings for authorized applications. For data + held by us, contact us via{" "} + + GitHub Issues + {" "} + and we will handle reasonable requests in line with applicable law. +

+ +

Changes

+

+ We may update this policy from time to time. The “Last updated” date + below will change when we do. +

+ +
+

+ Last updated · April 12, 2026 +

+
+
+ ); +} diff --git a/apps/dashboard/src/routes/terms.tsx b/apps/dashboard/src/routes/terms.tsx new file mode 100644 index 0000000..2ef528d --- /dev/null +++ b/apps/dashboard/src/routes/terms.tsx @@ -0,0 +1,100 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { LegalDocumentLayout } from "#/components/legal/legal-document-layout"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { siteConfig } from "#/lib/site-config"; + +const issuesUrl = `${siteConfig.githubRepositoryUrl}/issues`; + +export const Route = createFileRoute("/terms")({ + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Terms of Service"), + description: `Terms for using ${siteConfig.name} and how the service relates to GitHub.`, + robots: "index", + }), + component: TermsPage, +}); + +function TermsPage() { + return ( + +

+ These terms govern your use of {siteConfig.name} (the “Service”), + offered at {siteConfig.url}. By using the Service, you agree to these + terms. +

+ +

The Service

+

+ {siteConfig.name} is a dashboard that helps you view and work with + GitHub pull requests, issues, and related activity. The Service is + provided for your use subject to these terms and our{" "} + + Privacy Policy + + . +

+ +

GitHub

+

+ You must have a GitHub account and comply with GitHub’s terms and + policies when using GitHub. Signing in through GitHub is subject to + GitHub’s authentication and permission flows. Data creation, changes, + and permissions on repositories are ultimately governed by GitHub and + your settings on{" "} + + github.com + + . +

+ +

Your responsibilities

+
    +
  • You are responsible for activity under your account.
  • +
  • + You may not misuse the Service, attempt unauthorized access, or use it + in violation of law or third-party rights. +
  • +
+ +

Availability and changes

+

+ We may modify, suspend, or discontinue features (including during early + or beta periods). We strive for reliability but do not guarantee + uninterrupted or error-free operation. +

+ +

Disclaimer

+

+ The Service is provided “as is” without warranties of any kind, to the + maximum extent permitted by law. +

+ +

Limitation of liability

+

+ To the maximum extent permitted by law, we are not liable for indirect, + incidental, special, consequential, or punitive damages, or for loss of + profits, data, or goodwill, arising from your use of the Service. +

+ +

Contact

+

+ Questions about these terms: open a discussion on{" "} + + GitHub Issues + + . +

+ +
+

+ Last updated · April 12, 2026 +

+
+
+ ); +} diff --git a/extensions/diffkit-redirect/README.md b/extensions/diffkit-redirect/README.md index 1e40264..cf6a164 100644 --- a/extensions/diffkit-redirect/README.md +++ b/extensions/diffkit-redirect/README.md @@ -10,6 +10,7 @@ Standalone browser extension for redirecting only selected GitHub URLs to DiffKi - Uses a configurable list of rules instead of redirecting every GitHub page - Supports both exact URL redirects and regex-based route schemas - Supports custom route remaps like GitHub PR changes pages to DiffKit review pages +- On the DiffKit web app (`diff-kit.com` and local dev), sets `data-diffkit-extension="1"` on `` so the site can hide “install extension” prompts when the add-on is already loaded ## Default rule @@ -21,12 +22,16 @@ The extension ships with these enabled rules: - `https://diff-kit.com/pulls` - `https://github.com/issues/*` - `https://diff-kit.com/issues` +- `https://github.com/:owner/:repo` (repository overview; excludes `orgs`, `settings`, etc.) +- `https://diff-kit.com/:owner/:repo` - `https://github.com/:owner/:repo/pull/:number` - `https://diff-kit.com/:owner/:repo/pull/:number` - `https://github.com/:owner/:repo/pull/:number/changes` - `https://diff-kit.com/:owner/:repo/review/:number` - `https://github.com/:owner/:repo/issues/:number` - `https://diff-kit.com/:owner/:repo/issues/:number` +- `https://github.com/:owner` (profile; excludes global pages like `/pulls`, `/explore`, etc.) +- `https://diff-kit.com/:owner` ## Rule format diff --git a/extensions/diffkit-redirect/dashboard-presence.js b/extensions/diffkit-redirect/dashboard-presence.js new file mode 100644 index 0000000..8a202e6 --- /dev/null +++ b/extensions/diffkit-redirect/dashboard-presence.js @@ -0,0 +1,12 @@ +/** + * Runs on DiffKit web app origins so the site can detect the extension via DOM + * (`data-diffkit-extension` on ). Content scripts cannot share `window` + * with the page, but DOM attributes are visible to the app. + */ +(function markDiffKitExtensionPresent() { + try { + document.documentElement.dataset.diffkitExtension = "1"; + } catch { + // ignore + } +})(); diff --git a/extensions/diffkit-redirect/manifest.json b/extensions/diffkit-redirect/manifest.json index feec289..eba61d5 100644 --- a/extensions/diffkit-redirect/manifest.json +++ b/extensions/diffkit-redirect/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "DiffKit", - "version": "0.1.0", + "version": "0.1.1", "description": "Redirect selected GitHub URLs to matching DiffKit routes.", "icons": { "16": "icons/icon-16.png", @@ -10,7 +10,12 @@ "128": "icons/icon-128.png" }, "permissions": ["storage"], - "host_permissions": ["https://github.com/*"], + "host_permissions": [ + "https://github.com/*", + "https://diff-kit.com/*", + "http://localhost:3000/*", + "http://127.0.0.1:3000/*" + ], "action": { "default_title": "DiffKit", "default_popup": "popup.html", @@ -25,6 +30,15 @@ "matches": ["https://github.com/*"], "js": ["shared.js", "content.js"], "run_at": "document_start" + }, + { + "matches": [ + "https://diff-kit.com/*", + "http://localhost:3000/*", + "http://127.0.0.1:3000/*" + ], + "js": ["dashboard-presence.js"], + "run_at": "document_start" } ] } diff --git a/extensions/diffkit-redirect/shared.js b/extensions/diffkit-redirect/shared.js index f3f4352..7ce9c86 100644 --- a/extensions/diffkit-redirect/shared.js +++ b/extensions/diffkit-redirect/shared.js @@ -43,6 +43,22 @@ url: "https://diff-kit.com/issues", }, }, + { + id: "github-repo-overview", + label: "Repository overview", + description: + "Redirect GitHub repository home (two-segment path) to DiffKit repo overview.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/([^/?#]+)/([^/?#]+)/?(?:[?#].*)?$", + excludeUrlRegexes: [ + "^https://github\\.com/(?:orgs|new|settings|organizations|account)(?:/|$|[?#])", + ], + }, + redirect: { + replacement: "https://diff-kit.com/$1/$2", + }, + }, { id: "github-pull-details", label: "Pull request details", @@ -80,6 +96,22 @@ replacement: "https://diff-kit.com/$1/$2/issues/$3", }, }, + { + id: "github-user-profile", + label: "User profile", + description: + "Redirect GitHub user/org profile home (single-segment path) to DiffKit profile.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/([^/?#]+)/?(?:[?#].*)?$", + excludeUrlRegexes: [ + "^https://github\\.com/(?:pulls|issues|notifications|explore|marketplace|settings|login|join|sponsors?|topics|collections|codespaces|features|enterprise|team|pricing|resources|readme|security|opensource|copilot|education|orgs|organizations|new|account|watching|dashboard|sessions)(?:/|$|[?#])", + ], + }, + redirect: { + replacement: "https://diff-kit.com/$1", + }, + }, ]; function deepClone(value) { diff --git a/packages/ui/package.json b/packages/ui/package.json index 32606fa..1f184b4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@shikijs/rehype": "^4.0.2", + "@tailwindcss/typography": "^0.5.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.0", diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index f2485c3..cc16fcf 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -3,6 +3,7 @@ @import "tailwindcss"; @plugin "tailwindcss-animate"; +@plugin "@tailwindcss/typography"; @source "../../../apps/**/*.{ts,tsx}"; @source "../**/*.{ts,tsx}"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11500b3..45e6c59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,6 +246,9 @@ importers: '@shikijs/rehype': specifier: ^4.0.2 version: 4.0.2 + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.2.2) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2659,6 +2662,11 @@ packages: resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} engines: {node: '>= 20'} + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tailwindcss/vite@4.2.2': resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} peerDependencies: @@ -3324,6 +3332,11 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + cssstyle@6.2.0: resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} engines: {node: '>=20'} @@ -4235,6 +4248,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -4735,6 +4752,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: @@ -6920,6 +6940,11 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.2.2 + '@tailwindcss/vite@4.2.2(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.2.2 @@ -7668,6 +7693,8 @@ snapshots: css-what@6.2.2: {} + cssesc@3.0.0: {} + cssstyle@6.2.0: dependencies: '@asamuzakjp/css-color': 5.1.6 @@ -8782,6 +8809,11 @@ snapshots: picomatch@4.0.4: {} + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -9340,6 +9372,8 @@ snapshots: dependencies: react: 19.2.4 + util-deprecate@1.0.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) From 486af68adaa9d81c3536b1ae9fcca417ab7fa2a0 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sun, 12 Apr 2026 19:45:11 -0400 Subject: [PATCH 3/3] feat: browser-specific extension store links; Firefox manifest AMO fields - getExtensionStoreInstallUrl: Firefox AMO vs Chrome Web Store from userAgent - siteConfig: chrome + firefox listing URLs - Extension v0.1.3: data_collection_permissions (none), gecko_android min, min FF 140 / Fennec 142 - README: packaging and Firefox notes --- .../layouts/extension-install-prompt.tsx | 4 ++-- apps/dashboard/src/lib/extension-store-url.ts | 15 +++++++++++++++ apps/dashboard/src/lib/site-config.ts | 12 ++++++++---- extensions/diffkit-redirect/README.md | 13 +++++++++++++ extensions/diffkit-redirect/manifest.json | 14 +++++++++++++- 5 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 apps/dashboard/src/lib/extension-store-url.ts diff --git a/apps/dashboard/src/components/layouts/extension-install-prompt.tsx b/apps/dashboard/src/components/layouts/extension-install-prompt.tsx index aa03663..01cf185 100644 --- a/apps/dashboard/src/components/layouts/extension-install-prompt.tsx +++ b/apps/dashboard/src/components/layouts/extension-install-prompt.tsx @@ -8,7 +8,7 @@ import { recordExtensionInstallPromptDismissed, shouldShowExtensionInstallPrompt, } from "#/lib/extension-install-prompt-storage"; -import { siteConfig } from "#/lib/site-config"; +import { getExtensionStoreInstallUrl } from "#/lib/extension-store-url"; import { useHasMounted } from "#/lib/use-has-mounted"; export function ExtensionInstallPrompt() { @@ -55,7 +55,7 @@ export function ExtensionInstallPrompt() { return null; } - const installHref = siteConfig.browserExtensionInstallUrl; + const installHref = getExtensionStoreInstallUrl(); return (