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: 2 additions & 0 deletions .github/scripts/check-consumer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ try {

const rendered = run("node", ["ssr.mjs"], project).trim();
if (!rendered.includes("has stage: true")) throw new Error(`The server render came out wrong: ${rendered}`);
// A class given for a slot has to arrive on that part, through the packed packages too.
if (!rendered.includes("has a restyled slot: true")) throw new Error(`A slot's class did not land: ${rendered}`);
console.log(`ok ${rendered}`);
} catch (error) {
console.log(`FAIL\n${error.stdout ?? ""}${error.stderr ?? ""}${error.message ?? ""}`);
Expand Down
30 changes: 30 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,36 @@ specific than a single class and will reach the library's headings. Render a Wra
When code comes in from the app this library was cut from, its utility classes have no prefix yet:
`node scripts/prefix-classes.mjs packages/<name>/src --write` adds it (see `UPSTREAM.md`).

## Slots: every part of the chrome has a name

The tokens recolour the chrome. To change a shape, a size or a font on one part, a host needs to reach that part, so
every element of the chrome somebody might restyle is a slot: it carries `data-wr-slot="player-play"`, and it takes a
class for that name.

- **In a component** an element becomes a slot by spreading `slot(...)` from `useSlots`
(`packages/core/src/internal/slots.ts`): `<button {...slot("player-play", control, "wr:bg-foreground")}>`. The first
argument is the name, the rest are the library's classes, joined the way `cn` joins them.
- **From outside** a class arrives three ways, in this order on the element: `classNames` on `WrappedProvider` (for
everything under it), then the component's own `classNames`, both after the library's classes. And any stylesheet can
target `[data-wr-slot="player-play"]`.
- **Names** are kebab-case and start with their component: `player-`, `timeline-`, `settings-`, `share-`, `editor-`,
`scan-`, `gallery-`, `view-`. Each component exports its list (`WRAPPED_PLAYER_SLOTS`) and the union made from it
(`WrappedPlayerSlot`). A component that renders another takes that one's slots too and hands them on, so `WrappedView`
takes all of them in one flat object.
- **The shared pieces** in `core/src/internal` (chips, switch, dialog) have no names of their own. The component using
one says what it is called there: a chip is `settings-chip` in the settings and `editor-transition` in the editor.

Three rules when you add or change an element:

1. A new part gets a name in its component's list, and the docs' table for that component then fails the typecheck until
the name has a description (`apps/docs/src/registry/styling-page.ts`).
2. Every name in a list must be rendered in some state. The `slots.test.tsx` next to each component checks it.
3. A slot's name is public. Renaming one is a breaking change, like renaming a prop. The `data-*` attributes the code
itself reads (`data-seg`, `data-scan-grid`, `data-flyer-card`) are separate and stay as they are.

With no `classNames` anywhere a component renders exactly the classes it always did. The stage has no slots: a scene's
look is the palette's and the scene's own.

## The docs are generated from registries

`apps/docs/src/registry` is the source of the docs site. `charts.ts` and `transitions.ts` are keyed by the library's own
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,30 @@ body {
}
```

### Restyle a part

Every part of the controls is a slot with a name. It carries the name as `data-wr-slot`, and takes a class for it
through `classNames`, on one component or on `WrappedProvider` for the whole page.

```tsx
<WrappedPlayer
{...video}
classNames={{
"player-play": "my-play-button",
"timeline-segment-bar": "my-bar",
}}
/>
```

With Tailwind, import the stylesheet into a layer before utilities and your utilities win over the library's classes:

```css
@import "tailwindcss";
@import "@react-wrapped/kit/styles.css" layer(components);
```

The [styling guide](https://bettertyped.github.io/react-wrapped/docs/guides/styling/) lists every slot.

<p align="center">
<a href="https://github.com/sponsors/prc5?tier=Silver">
<picture>
Expand Down
12 changes: 12 additions & 0 deletions UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ node scripts/prefix-classes.mjs packages/core/src --write

It leaves prefixed classes and the library's own `wr-` classes alone, so it is safe to run again.

## The chrome's elements are slots here

In the app an element of the chrome has a plain `className`. Here every part somebody might restyle is a slot (see
`DESIGN.md`): `className={cn(control, "wr:bg-foreground")}` is written
`{...slot("player-play", control, "wr:bg-foreground")}`, which puts the same classes on the element plus its name. So a
diff against the app's file shows that rewrite on most elements of the player, the timeline, the settings, the scan, the
gallery and the editor, and nothing else about them.

When a change comes across, keep the slot and carry the classes into it. A new element that someone might want to
restyle gets a name in its component's list. The prefixer reads the classes inside `slot(...)` too and leaves the name
alone.

## Carrying a later change across

File names and the `wr-` class names are kept the same on purpose, so a change made on either side can be read as a diff
Expand Down
18 changes: 18 additions & 0 deletions apps/docs-e2e/tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ export const player = {
}, seconds),
};

/* ---------- the library's stylesheet ---------- */

/**
* The address of the library's stylesheet on the test server: the one the full example links whose rules style
* `.wr-stage`. Read from the built files, so a test that only needs the stylesheet does not have to open the
* example and play its video to find it.
*/
export function libraryStylesheetAddress(): string {
const html = builtFile("examples/full-wrapped/index.html");
const hrefs = [...html.matchAll(/<link\b[^>]*\brel="stylesheet"[^>]*>/g)].flatMap(([link]) => {
const href = /\bhref="([^"]+)"/.exec(link)?.[1];
return href?.startsWith(BASE) ? [href] : [];
});
const found = hrefs.find((href) => builtFile(href.slice(BASE.length)).includes(".wr-stage"));
if (!found) throw new Error(`None of the example's stylesheets has a .wr-stage rule: ${hrefs.join(", ")}`);
return found;
}

/* ---------- computed styles ---------- */

/** The computed value of each named property of an element (the first one a locator finds). */
Expand Down
124 changes: 124 additions & 0 deletions apps/docs-e2e/tests/layers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { expect, type Page, test } from "@playwright/test";

import { BASE, libraryStylesheetAddress, stylesOf } from "./helpers";

/*
* What the styling guide says about who wins, checked in a real browser. The library's stylesheet is plain CSS
* outside any cascade layer, and Tailwind v4 keeps its utilities in `@layer utilities`. A layered rule loses to an
* unlayered one, so a utility loses to the library's own class until the library's stylesheet is imported into a
* layer that comes before utilities. Each page here is built by hand: one library button and a few lines of CSS.
*/

const RED = "rgb(255, 0, 0)";
const BLUE = "rgb(0, 0, 255)";
const CLEAR = "rgba(0, 0, 0, 0)";

/** What Tailwind compiles `bg-red-500` to, as far as the cascade cares: one class, in the utilities layer. */
const UTILITY = `@layer utilities { .tw-red { background-color: ${RED} } }`;

/**
* A blank page of the site's own origin with one button of the library's on it: inside a root of the library's,
* wearing two of its own utility classes and whatever a host adds.
*/
const pageWith = ({ head, added }: { head: string; added: string }) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Layers</title>
${head}
</head>
<body>
<div class="wr-root">
<button type="button" id="probe" data-wr-slot="player-play" class="wr:rounded-full wr:bg-foreground ${added}">
x
</button>
</div>
</body>
</html>`;

/** Serves a hand-built page from the site's origin, opens it, and reads the button's background and corners. */
async function probe(page: Page, name: string, html: string) {
const address = `${BASE}__layers/${name}.html`;
await page.route(`**${address}`, (route) => route.fulfill({ contentType: "text/html", body: html }));
await page.goto(address);
const button = page.locator("#probe");
// The library's stylesheet is in effect once the button has the round corners only it gives.
await expect
.poll(async () => (await stylesOf(button, ["border-top-left-radius"]))["border-top-left-radius"], {
message: "the library's stylesheet is loaded and styles the button",
})
.not.toBe("0px");
return stylesOf(button, ["background-color", "border-top-left-radius"]);
}

test.describe("a Tailwind utility on a library element", () => {
test("loses to the library's own class while the library's stylesheet is loaded plainly", async ({ page }) => {
// Arrange: the library's stylesheet as a plain link, and the utility in its layer after it.
const stylesheet = libraryStylesheetAddress();
const html = pageWith({
head: `<link rel="stylesheet" href="${stylesheet}" /><style>${UTILITY}</style>`,
added: "tw-red",
});

// Act
const styles = await probe(page, "plain", html);

// Assert: the button keeps the library's foreground colour.
expect(styles["background-color"]).not.toBe(RED);
expect(styles["background-color"]).not.toBe(CLEAR);
});

test("wins once the library's stylesheet is imported into a layer before utilities", async ({ page }) => {
// Arrange: the same rules, with the library's stylesheet in `components`. The order of the layers is said
// first, in a statement, because nothing but that may stand before an import.
const stylesheet = libraryStylesheetAddress();
const html = pageWith({
head: `<style>@layer components, utilities; @import url("${stylesheet}") layer(components); ${UTILITY}</style>`,
added: "tw-red",
});

// Act
const styles = await probe(page, "layered", html);

// Assert: the utility has the background, and the library still has everything the utility did not name.
expect(styles["background-color"]).toBe(RED);
expect(styles["border-top-left-radius"]).not.toBe("0px");
});
});

test.describe("a plain class on a library element", () => {
test("wins over the library's class when its stylesheet comes after the library's", async ({ page }) => {
// Arrange
const stylesheet = libraryStylesheetAddress();
const html = pageWith({
head: `<link rel="stylesheet" href="${stylesheet}" /><style>.mine { background-color: ${BLUE} }</style>`,
added: "mine",
});

// Act
const styles = await probe(page, "later", html);

// Assert
expect(styles["background-color"]).toBe(BLUE);
});

test("loses when its stylesheet comes first, unless the selector names the slot too", async ({ page }) => {
// Arrange: two pages with the host's rule before the library's stylesheet. One is a bare class, the other
// has the slot's attribute in it as well.
const stylesheet = libraryStylesheetAddress();
const early = (rule: string) =>
pageWith({ head: `<style>${rule}</style><link rel="stylesheet" href="${stylesheet}" />`, added: "mine" });

// Act
const bare = await probe(page, "early-bare", early(`.mine { background-color: ${BLUE} }`));
const slotted = await probe(
page,
"early-slotted",
early(`.mine[data-wr-slot="player-play"] { background-color: ${BLUE} }`),
);

// Assert
expect(bare["background-color"]).not.toBe(BLUE);
expect(slotted["background-color"]).toBe(BLUE);
});
});
7 changes: 7 additions & 0 deletions apps/docs/src/demos/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { WaitingForTheYear } from "./loading";
import { MarkPicker } from "./marks";
import { OwnChrome } from "./own-chrome";
import { OwnColour } from "./own-colour";
import { OwnStyle } from "./own-style";
import { OwnTransport } from "./own-transport";
import { PlayerRemote } from "./player-remote";
import { Rarities } from "./rarities";
Expand Down Expand Up @@ -167,6 +168,11 @@ function OwnChromePart() {
return <OwnChrome subject={subject} seed={seed} scenes={SHORT_SCENES} config={config} onConfigChange={change} />;
}

function OwnStylePart() {
const [config, change] = useOwnConfig();
return <OwnStyle subject={subject} seed={seed} scenes={SHORT_SCENES} config={config} onConfigChange={change} />;
}

export const PARTS: Record<PartId, { render: () => ReactNode; look: boolean }> = {
// These keep a whole config of their own, the way an app does, so the site's look controls do not reach them.
"first-wrapped": { render: () => <FirstWrapped />, look: false },
Expand All @@ -192,4 +198,5 @@ export const PARTS: Record<PartId, { render: () => ReactNode; look: boolean }> =
marks: { render: () => <MarksPart />, look: true },
"own-colour": { render: () => <OwnColourPart />, look: true },
"own-chrome": { render: () => <OwnChromePart />, look: true },
"own-style": { render: () => <OwnStylePart />, look: true },
};
97 changes: 97 additions & 0 deletions apps/docs/src/demos/own-style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { type CSSProperties, useState } from "react";

import type { SceneInstance, SlotClassNames, WrappedConfig, WrappedSubject } from "@react-wrapped/core";
import {
type ConfiguratorSlot,
type TimelineSlot,
WrappedConfigurator,
WrappedPlayer,
type WrappedPlayerSlot,
} from "@react-wrapped/player";

// Plain CSS classes of your own. This stylesheet comes after the library's, so on the same element a class here
// wins over the library's class. They read the chrome tokens, so they follow light and dark like everything else.
const CSS = `
.orbit-frame { border-radius: 4px; }
.orbit-button {
border-radius: 6px;
background: var(--wr-ui-primary);
color: var(--wr-ui-primary-fg);
box-shadow: 0 3px 0 color-mix(in oklch, var(--wr-ui-primary) 62%, black);
}
.orbit-clock { font-family: ui-monospace, monospace; color: var(--wr-ui-fg); }
.orbit-bar { border-radius: 2px; background: var(--wr-ui-border); }
.orbit-label { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--wr-ui-muted-fg); }
.orbit-chip {
border-radius: 6px;
background: transparent;
box-shadow: inset 0 0 0 1.5px var(--wr-ui-border);
}
/* The library styles a picked chip with the state in its selector, which outweighs a bare class. Name the state. */
.orbit-chip[aria-checked="true"] {
background: var(--wr-ui-primary);
color: var(--wr-ui-primary-fg);
box-shadow: none;
}
.orbit-swatch { border-radius: 6px; }
`;

// A class per slot. The player takes its own slots and the timeline's.
const PLAYER: SlotClassNames<WrappedPlayerSlot | TimelineSlot> = {
"player-frame": "orbit-frame",
"player-play": "orbit-button",
"player-restart": "orbit-button",
"player-fullscreen": "orbit-button",
"player-clock": "orbit-clock",
"timeline-segment-bar": "orbit-bar",
};

const SETTINGS: SlotClassNames<ConfiguratorSlot> = {
"settings-label": "orbit-label",
"settings-chip": "orbit-chip",
"settings-swatch": "orbit-swatch",
};

// Side by side where there is room, stacked on a phone.
const COLUMNS: CSSProperties = { display: "flex", flexWrap: "wrap", gap: 24, alignItems: "flex-start", marginTop: 14 };
const ROOM: CSSProperties = { display: "grid", height: 520, flex: "1 1 260px", minWidth: 0 };
const PANEL: CSSProperties = { flex: "1 1 300px", minWidth: 0 };

export function OwnStyle({
subject,
seed,
scenes,
config,
onConfigChange,
}: {
subject: WrappedSubject;
seed: string;
scenes: readonly SceneInstance[];
config: WrappedConfig;
onConfigChange: (next: WrappedConfig) => void;
}) {
const [styled, setStyled] = useState(true);
return (
<div>
<style>{CSS}</style>
<label>
<input type="checkbox" checked={styled} onChange={(event) => setStyled(event.target.checked)} />
Use our classes
</label>
<div style={COLUMNS}>
<div style={ROOM}>
<WrappedPlayer
subject={subject}
seed={seed}
scenes={scenes}
config={config}
classNames={styled ? PLAYER : undefined}
/>
</div>
<div style={PANEL}>
<WrappedConfigurator config={config} onChange={onConfigChange} classNames={styled ? SETTINGS : undefined} />
</div>
</div>
</div>
);
}
Loading
Loading