Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/rules/ls-foundry-core.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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**) |
Expand Down
4 changes: 4 additions & 0 deletions packages/helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a href="https://liteshademedia.com">` (`LSM_BRAND_HOME_URL`). Pass a custom `href` string, or `href={false}` for a non-link `<div>` when the app already wraps with Next.js `Link`. Optional `referral` appends `?ref=<value>` (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` → `<div>` |
| `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` |
Expand Down
1 change: 1 addition & 0 deletions packages/helpers/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/helpers/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
51 changes: 43 additions & 8 deletions packages/helpers/src/brand/LiteShadeBrand.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,8 +26,8 @@ import {
const DEFAULT_HOVER_BLINK_MS = 900;

export type LiteShadeBrandProps = Omit<
HTMLAttributes<HTMLDivElement>,
"children" | "color"
HTMLAttributes<HTMLElement>,
"children" | "color" | "href"
> & {
/** Applied to mark + wordmark (and root `color`). Default `"currentColor"`. */
color?: string;
Expand All @@ -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 (`<a>`). Default
* {@link LSM_BRAND_HOME_URL}. Pass `false` to render a non-link `<div>`
* (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`.
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -87,13 +106,24 @@ 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",
gap,
color,
fontSize: "0.875rem",
letterSpacing: "0.25em",
...(isLink
? {
textDecoration: "none",
cursor: "pointer",
}
: null),
...style,
};

Expand All @@ -106,12 +136,12 @@ export function LiteShadeBrand({
setBlinkReplayToken((token) => token + 1);
};

const handleMouseEnter = (event: MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = (event: MouseEvent<HTMLElement>) => {
replayHoverBlink();
onMouseEnter?.(event);
};

const handleFocus = (event: FocusEvent<HTMLDivElement>) => {
const handleFocus = (event: FocusEvent<HTMLElement>) => {
// Focus on the brand root (or bubbled from a focusable child).
replayHoverBlink();
onFocus?.(event);
Expand All @@ -123,11 +153,16 @@ export function LiteShadeBrand({
? hoverBlinkDurationMs
: mountBlinkDurationMs;

const Tag = (isLink ? "a" : "div") as ElementType;

return (
<div
<Tag
className={className}
style={mergedStyle}
data-lsm-brand=""
href={resolvedHref}
rel={isLink ? "noopener noreferrer" : undefined}
aria-label={isLink ? "LiteShadeMedia" : undefined}
{...(hoverEffects ? slidingTextGroupProps : null)}
onMouseEnter={handleMouseEnter}
onFocus={handleFocus}
Expand All @@ -152,6 +187,6 @@ export function LiteShadeBrand({
slideProps={slideProps}
/>
) : null}
</div>
</Tag>
);
}
43 changes: 43 additions & 0 deletions packages/helpers/src/brand/LiteShadeMark.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -281,6 +285,45 @@ describe("LiteShadeBrand", () => {
).toBe("true");
});

it("appends referral as ref query param and allows custom href", () => {
const { container, rerender } = render(
<LiteShadeBrand
referral="stickpak"
markProps={{ blinkDisabled: true }}
wordmarkProps={{ disabled: true }}
/>,
);
expect(container.querySelector("[data-lsm-brand]")?.getAttribute("href")).toBe(
"https://liteshademedia.com/?ref=stickpak",
);

rerender(
<LiteShadeBrand
href="https://example.com/brand"
referral="nav"
markProps={{ blinkDisabled: true }}
wordmarkProps={{ disabled: true }}
/>,
);
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(
<LiteShadeBrand
href={false}
markProps={{ blinkDisabled: true }}
wordmarkProps={{ disabled: true }}
/>,
);
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(
<LiteShadeBrand
Expand Down
32 changes: 32 additions & 0 deletions packages/helpers/src/brand/brandHref.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
buildLiteShadeBrandHref,
LSM_BRAND_HOME_URL,
} from "./brandHref";

describe("buildLiteShadeBrandHref", () => {
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");
});
});
22 changes: 22 additions & 0 deletions packages/helpers/src/brand/brandHref.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
}
4 changes: 4 additions & 0 deletions packages/helpers/src/brand/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ export {
LiteShadeBrand,
type LiteShadeBrandProps,
} from "./LiteShadeBrand";
export {
buildLiteShadeBrandHref,
LSM_BRAND_HOME_URL,
} from "./brandHref";
export {
LiteShadeMark,
type LiteShadeMarkProps,
Expand Down
Loading