diff --git a/.cursor/rules/ls-foundry-core.mdc b/.cursor/rules/ls-foundry-core.mdc index e8bcc37..9f52a68 100644 --- a/.cursor/rules/ls-foundry-core.mdc +++ b/.cursor/rules/ls-foundry-core.mdc @@ -54,7 +54,7 @@ Always bump `shared-types` first when changing layout/DPI APIs; keep dependent p | Package | Version | Docs | |---------|---------|------| -| `@jeffgo10/helpers` | 1.4.0 | `packages/helpers/` (`./image`, `./gestures`, `./browser`, `./clipboard`, `./text`, `./brand`, `./ui`) | +| `@jeffgo10/helpers` | 1.5.0 | `packages/helpers/` (`./image`, `./gestures`, `./browser`, `./clipboard`, `./text`, `./brand`, `./ui`) | | `@jeffgo10/history` | 1.0.0 | `packages/history/` — generic undo/redo snapshot stacks | | `@jeffgo10/three-d-label-customizer` | 0.1.0 | `docs/three-d-label-customizer/` | | `@jeffgo10/gl-viewer` | 1.0.0 | `packages/gl-viewer/` (consumer: **LiteShadeMedia**) | diff --git a/packages/helpers/README.md b/packages/helpers/README.md index bafcede..3e7f77b 100644 --- a/packages/helpers/README.md +++ b/packages/helpers/README.md @@ -192,12 +192,16 @@ Inline SVG converted from LiteShadeMedia `public/lsm-white.svg` / `lsm-black.svg **Brand hover (default on):** `LiteShadeBrand` puts `data-sliding-text-group` on the root, wraps the wordmark in `SlidingText`, and on `mouseenter` / `focus` re-blinks the mark (shorter `hoverBlinkDurationMs`, default `900`) in parallel with the slide. Initial scramble + mount blink still run once. Set `hoverEffects={false}` to opt out. +**Brand link (default on):** root renders as `` (`LSM_BRAND_HOME_URL`). Pass a custom `href` string, or `href={false}` for a non-link `
` when the app already wraps with Next.js `Link`. Optional `referral` appends `?ref=` (prop is named `referral` so it does not collide with React’s `ref`). + | Prop | Components | Default | Notes | |------|------------|---------|--------| | `color` | Mark / Wordmark / Brand | `currentColor` | Sets CSS `color` | | `size` | Mark / Brand | `24` | SVG width & height | | `showMark` / `showWordmark` | Brand | `true` | Toggle parts | | `gap` | Brand | `0.5rem` | Flex gap | +| `href` | Brand | `https://liteshademedia.com` | Brand root link; `false` → `
` | +| `referral` | Brand | — | Appended as `?ref=` on the href | | `hoverEffects` | Brand | `true` | Slide wordmark + re-blink mark on hover | | `hoverBlinkDurationMs` | Brand | `900` | Fluorescent window for hover replay | | `slideProps` | Brand | — | Forwarded to wordmark `SlidingText` | diff --git a/packages/helpers/jest.config.cjs b/packages/helpers/jest.config.cjs index 69aed40..8d59fd5 100644 --- a/packages/helpers/jest.config.cjs +++ b/packages/helpers/jest.config.cjs @@ -27,6 +27,7 @@ module.exports = { "./src/brand/LiteShadeMark.tsx": threshold90, "./src/brand/LiteShadeWordmark.tsx": threshold90, "./src/brand/LiteShadeBrand.tsx": threshold90, + "./src/brand/brandHref.ts": threshold90, "./src/brand/paths.ts": threshold90, "./src/brand/fluorescentBlink.ts": { ...threshold90, diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 894cfad..38b4112 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@jeffgo10/helpers", - "version": "1.4.0", + "version": "1.5.0", "description": "Shared helper utilities for ls-foundry packages", "license": "MIT", "repository": { diff --git a/packages/helpers/src/brand/LiteShadeBrand.tsx b/packages/helpers/src/brand/LiteShadeBrand.tsx index 02eb85f..87e1190 100644 --- a/packages/helpers/src/brand/LiteShadeBrand.tsx +++ b/packages/helpers/src/brand/LiteShadeBrand.tsx @@ -1,13 +1,18 @@ import { useState, type CSSProperties, + type ElementType, + type FocusEvent, type HTMLAttributes, type MouseEvent, - type FocusEvent, } from "react"; import { slidingTextGroupProps } from "@jeffgo10/helpers/ui"; +import { + buildLiteShadeBrandHref, + LSM_BRAND_HOME_URL, +} from "./brandHref"; import { LiteShadeMark, type LiteShadeMarkProps, @@ -21,8 +26,8 @@ import { const DEFAULT_HOVER_BLINK_MS = 900; export type LiteShadeBrandProps = Omit< - HTMLAttributes, - "children" | "color" + HTMLAttributes, + "children" | "color" | "href" > & { /** Applied to mark + wordmark (and root `color`). Default `"currentColor"`. */ color?: string; @@ -36,6 +41,17 @@ export type LiteShadeBrandProps = Omit< showWordmark?: boolean; /** Flex gap between mark and wordmark. Default `"0.5rem"`. */ gap?: number | string; + /** + * Destination URL for the brand root (``). Default + * {@link LSM_BRAND_HOME_URL}. Pass `false` to render a non-link `
` + * (e.g. when the consumer already wraps with Next.js `Link`). + */ + href?: string | false; + /** + * Optional referral / campaign code appended as the `ref` query param. + * Named `referral` so it does not collide with React’s `ref`. + */ + referral?: string; /** * On hover / focus-within: wordmark vertical slide + mark fluorescent re-blink. * Initial mount scramble / blink still run. Default `true`. @@ -64,7 +80,8 @@ export type LiteShadeBrandProps = Omit< * Wordmark always reads `LITESHADEMEDIA` with scramble via `useScrambleReveal`. * Provide {@link ScrambleRevealProvider} in the consumer app. * - * Hover (default): sliding wordmark + fluorescent re-blink on the mark, in parallel. + * By default the root is a link to {@link LSM_BRAND_HOME_URL}. Hover (default): + * sliding wordmark + fluorescent re-blink on the mark, in parallel. */ export function LiteShadeBrand({ color = "currentColor", @@ -73,6 +90,8 @@ export function LiteShadeBrand({ showMark = true, showWordmark = true, gap = "0.5rem", + href = LSM_BRAND_HOME_URL, + referral, hoverEffects = true, hoverBlinkDurationMs = DEFAULT_HOVER_BLINK_MS, markProps, @@ -87,6 +106,11 @@ export function LiteShadeBrand({ void _forbiddenLabel; const [blinkReplayToken, setBlinkReplayToken] = useState(0); + const isLink = href !== false; + const resolvedHref = isLink + ? buildLiteShadeBrandHref(href, referral) + : undefined; + const mergedStyle: CSSProperties = { display: "flex", alignItems: "center", @@ -94,6 +118,12 @@ export function LiteShadeBrand({ color, fontSize: "0.875rem", letterSpacing: "0.25em", + ...(isLink + ? { + textDecoration: "none", + cursor: "pointer", + } + : null), ...style, }; @@ -106,12 +136,12 @@ export function LiteShadeBrand({ setBlinkReplayToken((token) => token + 1); }; - const handleMouseEnter = (event: MouseEvent) => { + const handleMouseEnter = (event: MouseEvent) => { replayHoverBlink(); onMouseEnter?.(event); }; - const handleFocus = (event: FocusEvent) => { + const handleFocus = (event: FocusEvent) => { // Focus on the brand root (or bubbled from a focusable child). replayHoverBlink(); onFocus?.(event); @@ -123,11 +153,16 @@ export function LiteShadeBrand({ ? hoverBlinkDurationMs : mountBlinkDurationMs; + const Tag = (isLink ? "a" : "div") as ElementType; + return ( -
) : null} -
+ ); } diff --git a/packages/helpers/src/brand/LiteShadeMark.test.tsx b/packages/helpers/src/brand/LiteShadeMark.test.tsx index f684ec6..63ff44d 100644 --- a/packages/helpers/src/brand/LiteShadeMark.test.tsx +++ b/packages/helpers/src/brand/LiteShadeMark.test.tsx @@ -4,6 +4,7 @@ import { resetSkipEnvironmentCache } from "../text/skipEnvironment"; import { LiteShadeBrand } from "./LiteShadeBrand"; import { LiteShadeMark } from "./LiteShadeMark"; import { LiteShadeWordmark } from "./LiteShadeWordmark"; +import { LSM_BRAND_HOME_URL } from "./brandHref"; import { LSM_BRAND_LABEL, LSM_PATH_INNER_A, @@ -259,6 +260,9 @@ describe("LiteShadeBrand", () => { />, ); const root = container.querySelector("[data-lsm-brand]"); + expect(root?.tagName).toBe("A"); + expect(root?.getAttribute("href")).toBe(LSM_BRAND_HOME_URL); + expect(root?.getAttribute("rel")).toBe("noopener noreferrer"); expect(root).toHaveStyle({ display: "flex", alignItems: "center", @@ -281,6 +285,45 @@ describe("LiteShadeBrand", () => { ).toBe("true"); }); + it("appends referral as ref query param and allows custom href", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector("[data-lsm-brand]")?.getAttribute("href")).toBe( + "https://liteshademedia.com/?ref=stickpak", + ); + + rerender( + , + ); + expect(container.querySelector("[data-lsm-brand]")?.getAttribute("href")).toBe( + "https://example.com/brand?ref=nav", + ); + }); + + it("renders a non-link div when href is false", () => { + const { container } = render( + , + ); + const root = container.querySelector("[data-lsm-brand]"); + expect(root?.tagName).toBe("DIV"); + expect(root?.getAttribute("href")).toBeNull(); + expect(root?.getAttribute("rel")).toBeNull(); + }); + it("can hide mark or wordmark", () => { const { container, rerender } = render( { + it("returns the href unchanged when referral is omitted", () => { + expect(buildLiteShadeBrandHref(LSM_BRAND_HOME_URL)).toBe(LSM_BRAND_HOME_URL); + expect(buildLiteShadeBrandHref("/about")).toBe("/about"); + }); + + it("appends ref on absolute URLs", () => { + expect(buildLiteShadeBrandHref(LSM_BRAND_HOME_URL, "stickpak")).toBe( + "https://liteshademedia.com/?ref=stickpak", + ); + }); + + it("overwrites an existing ref param", () => { + expect( + buildLiteShadeBrandHref("https://liteshademedia.com/?ref=old", "new"), + ).toBe("https://liteshademedia.com/?ref=new"); + }); + + it("appends ref on relative paths (with or without existing query)", () => { + expect(buildLiteShadeBrandHref("/home", "nav")).toBe("/home?ref=nav"); + expect(buildLiteShadeBrandHref("/home?x=1", "nav")).toBe("/home?x=1&ref=nav"); + }); + + it("encodes referral values", () => { + expect(buildLiteShadeBrandHref("/x", "a b")).toBe("/x?ref=a%20b"); + }); +}); diff --git a/packages/helpers/src/brand/brandHref.ts b/packages/helpers/src/brand/brandHref.ts new file mode 100644 index 0000000..4ff4812 --- /dev/null +++ b/packages/helpers/src/brand/brandHref.ts @@ -0,0 +1,22 @@ +/** Default destination for {@link LiteShadeBrand} when used as a link. */ +export const LSM_BRAND_HOME_URL = "https://liteshademedia.com"; + +/** + * Resolve a brand href, optionally appending a `ref` query param. + * Absolute URLs use the `URL` API; relative paths append `?ref=` / `&ref=`. + */ +export function buildLiteShadeBrandHref( + href: string, + referral?: string, +): string { + if (!referral) return href; + + try { + const url = new URL(href); + url.searchParams.set("ref", referral); + return url.toString(); + } catch { + const join = href.includes("?") ? "&" : "?"; + return `${href}${join}ref=${encodeURIComponent(referral)}`; + } +} diff --git a/packages/helpers/src/brand/index.ts b/packages/helpers/src/brand/index.ts index cb7be8a..ab9b11a 100644 --- a/packages/helpers/src/brand/index.ts +++ b/packages/helpers/src/brand/index.ts @@ -2,6 +2,10 @@ export { LiteShadeBrand, type LiteShadeBrandProps, } from "./LiteShadeBrand"; +export { + buildLiteShadeBrandHref, + LSM_BRAND_HOME_URL, +} from "./brandHref"; export { LiteShadeMark, type LiteShadeMarkProps,