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
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const ChecklistCard: FC = () => {
<li key={item.id} className="mt-1">
<button
type="button"
className="c-focus-ring inline-flex w-full items-center justify-center rounded-3xl bg-accent px-4 py-1.5 font-medium text-on-accent text-xs transition-all hover:brightness-110"
className="c-button-compact c-button-primary w-full justify-center rounded-3xl px-4 py-1.5 text-xs"
onClick={() => {
track("signup_started", { source: "checklist" });
openModal("signUp");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,16 @@ describe("ShortcutShowcase", () => {
expect(screen.queryByTestId("shift-icon")).toBeNull();
});

it("puts undo and redo chips in the undoRedo sentence, not a duplicate row", () => {
render(<ShortcutShowcase />);
showStep("undoRedo");

expect(screen.getByText("to undo your last change, then")).toBeTruthy();
expect(screen.getByText("to bring it back.")).toBeTruthy();
expect(screen.getAllByTestId("z-icon")).toHaveLength(2);
expect(screen.getByTestId("shift-icon")).toBeTruthy();
});

it("Escape confirms once, lesson keys fall through, second Escape skips", () => {
render(<ShortcutShowcase />);
act(() => shortcutShowcaseActions.start());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys";
import { useAppLockReason } from "@web/shortcuts/app-lock";
import { isBareLetterKey } from "@web/shortcuts/is-bare-letter-key";
import { KEYMAP } from "@web/shortcuts/keymap";
import { ShortcutTipParts } from "@web/shortcuts/tips/ShortcutTipParts";
import { ARM_WINDOW_MS } from "@web/shortcuts/useEditSequenceShortcut";

const TEXT_BUTTON_CLASS =
Expand Down Expand Up @@ -453,7 +454,13 @@ const ShowcaseTakeover: FC = () => {
</div>
</div>
<h2 className="font-semibold text-lg text-text">{step.title}</h2>
<p className="text-sm text-text-muted">{step.body}</p>
<p className="text-sm text-text-muted">
{typeof step.body === "string" ? (
step.body
) : (
<ShortcutTipParts parts={step.body} />
)}
</p>
{keycaps && <ShortcutKeys keys={[...keycaps]} />}
<div className="flex items-center gap-2 pt-2">
{stepId === "graduation" ? (
Expand Down
23 changes: 16 additions & 7 deletions packages/web/src/components/ShortcutShowcase/showcase.steps.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { detectPlatform } from "@tanstack/react-hotkeys";
import { KEYMAP } from "@web/shortcuts/keymap";

// Prose has no keycap chips to expand "Mod" into, so spell the real key out.
const MOD_KEY = detectPlatform() === "mac" ? "Cmd" : "Ctrl";
import { type ShortcutTipPart } from "@web/shortcuts/tips/shortcut-tips.data";

/**
* Single source of truth for showcase step order. Every shortcut concept the
Expand Down Expand Up @@ -30,7 +27,12 @@ export const SHOWCASE_STEP_IDS: readonly ShowcaseStepId[] = STEP_IDS;
export type ShowcaseStep = {
id: ShowcaseStepId;
title: string;
body: string;
/**
* Plain copy, or the same parts model as shortcut tips so a step can put
* real keycap chips in the sentence. Only undoRedo needs that; converting
* every body would duplicate the chip row already rendered from `keycaps`.
*/
body: string | readonly ShortcutTipPart[];
/** One keycap per entry, rendered via ShortcutKeys. */
keycaps?: readonly string[];
};
Expand Down Expand Up @@ -92,8 +94,15 @@ const STEP_CONTENT: Record<ShowcaseStepId, Omit<ShowcaseStep, "id">> = {
},
undoRedo: {
title: "Never stress a mistake",
body: `Press ${MOD_KEY}+Z to undo your last change, then ${MOD_KEY}+Shift+Z to bring it back.`,
keycaps: KEYMAP.undo.keycaps,
// Chips live in the sentence so both chords render; a second keycap row
// would duplicate undo and still hide redo.
body: [
"Press ",
{ keys: KEYMAP.undo.keycaps },
" to undo your last change, then ",
{ keys: KEYMAP.redo.keycaps },
" to bring it back.",
],
},
hardcore: {
title: "Try Hardcore Mode",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
const ANONYMOUS_SAVE_MESSAGE = "Sign up to save your changes across browsers";

const TOOLTIP_ACTION_BUTTON_CLASSNAME =
"c-focus-ring self-start rounded-xs bg-accent px-2 py-1 font-medium text-s text-on-accent hover:brightness-110";
"c-button-compact c-button-primary self-start rounded-xs px-2 py-1 text-s";

interface AnonymousCalendarRowProps {
calendar: Calendar;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const HEADING_CLASSNAME =
"flex min-w-0 flex-1 font-semibold text-sm leading-none";

const CONNECT_GOOGLE_BUTTON_CLASSNAME =
"c-focus-ring mb-2 w-full rounded-xs bg-accent px-2 py-1.5 text-left font-medium text-on-accent text-xs hover:brightness-110 disabled:pointer-events-none disabled:opacity-60";
"c-button-compact c-button-primary mb-2 w-full rounded-xs px-2 py-1.5 text-left text-xs";

/**
* The heading shown before any account section exists for a signed-in user:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function createTasksRemovalNotice({
</div>

<button
className="c-focus-ring self-start rounded-xs bg-accent px-2 py-1 font-medium text-on-accent text-s hover:brightness-110 disabled:opacity-60"
className="c-button-compact c-button-primary self-start rounded-xs px-2 py-1 text-s"
disabled={exportStatus === "exporting"}
onClick={handleExport}
type="button"
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/components/WelcomeModal/WelcomeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,15 @@ export function WelcomeModal() {
<button
type="button"
onClick={() => handOffToAuth("sign_up")}
className="inline-flex items-center rounded-3xl bg-accent px-4 py-1.5 text-on-accent text-xs transition-all hover:brightness-110"
className="c-button-compact c-button-primary rounded-3xl px-4 py-1.5 text-xs"
>
Sign up
<ShortcutHint className="ml-2">U</ShortcutHint>
</button>
<button
type="button"
onClick={() => handOffToAuth("log_in")}
className="inline-flex items-center rounded-3xl bg-[#c2c6cc] px-4 py-1.5 text-[#1f1f1f] text-xs transition-all hover:bg-[#d1d5da]"
className="c-button-compact c-button-secondary rounded-3xl px-4 py-1.5 text-xs"
>
Log in
<ShortcutHint className="ml-2">I</ShortcutHint>
Expand Down
6 changes: 6 additions & 0 deletions packages/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,12 @@
@apply border border-border bg-surface-overlay text-text hover:bg-surface-panel;
}

/* Compact rows (checklist, sidebar, welcome pills): same color/hover as
c-button-primary, without the default c-button height, shadow, or lift. */
@utility c-button-compact {
@apply c-focus-ring inline-flex items-center font-medium transition-[background-color] duration-150 ease-out disabled:pointer-events-none disabled:opacity-60;
}

@utility c-disclosure-content {
@apply invisible grid grid-rows-[0fr] overflow-hidden opacity-0 transition-[grid-template-rows,opacity,visibility] duration-200 ease-out motion-reduce:transition-none;
transition-behavior: allow-discrete;
Expand Down
11 changes: 10 additions & 1 deletion packages/web/src/shortcuts/keymap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,17 @@ describe("keymap ↔ showcase hint parity", () => {
expect(getShowcaseStep("placeDraft").keycaps).toBe(
KEYMAP.moveEvent.keycaps,
);
expect(getShowcaseStep("undoRedo").keycaps).toBe(KEYMAP.undo.keycaps);
expect(getShowcaseStep("hardcore").keycaps).toBe(KEYMAP.hardcore.keycaps);

const undoRedoBody = getShowcaseStep("undoRedo").body;
expect(Array.isArray(undoRedoBody)).toBe(true);
const undoRedoChords = (
undoRedoBody as readonly { keys?: readonly string[] }[]
)
.filter((part) => typeof part !== "string" && "keys" in part)
.map((part) => part.keys);
expect(undoRedoChords[0]).toBe(KEYMAP.undo.keycaps);
expect(undoRedoChords[1]).toBe(KEYMAP.redo.keycaps);
});
});

Expand Down
15 changes: 2 additions & 13 deletions packages/web/src/shortcuts/tips/ShortcutTipIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type FC } from "react";
import { track } from "@web/auth/posthog/track";
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
import { ShortcutTipParts } from "@web/shortcuts/tips/ShortcutTipParts";
import {
getShortcutTips,
getTipPlainText,
Expand Down Expand Up @@ -43,18 +43,7 @@ export const ShortcutTipIndicator: FC = () => {
type="button"
>
<span aria-live="polite" role="status">
<span className="sr-only">{plainText}</span>
<span aria-hidden className="inline-flex items-center gap-1">
{tip.parts.map((part, i) =>
typeof part === "string" ? (
// biome-ignore lint/suspicious/noArrayIndexKey: parts are a fixed, order-stable literal per tip
<span key={i}>{part}</span>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: parts are a fixed, order-stable literal per tip
<ShortcutHint key={i}>{part.key}</ShortcutHint>
),
)}
</span>
<ShortcutTipParts parts={tip.parts} />
</span>
</button>
);
Expand Down
37 changes: 37 additions & 0 deletions packages/web/src/shortcuts/tips/ShortcutTipParts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type FC } from "react";
import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys";
import {
getPartsPlainText,
type ShortcutTipPart,
} from "@web/shortcuts/tips/shortcut-tips.data";

const partKeycaps = (
part: Exclude<ShortcutTipPart, string>,
): readonly string[] => ("keys" in part ? part.keys : [part.key]);

/**
* Prose with inline keycap chips. The visible chips are aria-hidden; the
* reconstituted sentence is the accessible name.
*/
export const ShortcutTipParts: FC<{
parts: readonly ShortcutTipPart[];
}> = ({ parts }) => {
const plainText = getPartsPlainText(parts);

return (
<span>
<span className="sr-only">{plainText}</span>
<span aria-hidden className="inline-flex flex-wrap items-center gap-1">
{parts.map((part, i) =>
typeof part === "string" ? (
// biome-ignore lint/suspicious/noArrayIndexKey: parts are a fixed, order-stable literal
<span key={i}>{part}</span>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: parts are a fixed, order-stable literal
<ShortcutKeys key={i} keys={[...partKeycaps(part)]} />
),
)}
</span>
</span>
);
};
14 changes: 14 additions & 0 deletions packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { expandModInShortcutDisplay } from "@web/shortcuts/shortcut.util";
import {
getPartsPlainText,
getShortcutTips,
getTipPlainText,
} from "@web/shortcuts/tips/shortcut-tips.data";
Expand All @@ -23,4 +25,16 @@ describe("getTipPlainText", () => {
"Press Tab to move between start and end",
);
});

it("joins a chord's keys with + and speaks Mod as Cmd or Ctrl", () => {
const mod = expandModInShortcutDisplay("Mod") === "Meta" ? "Cmd" : "Ctrl";
expect(
getPartsPlainText([
"Press ",
{ keys: ["Mod", "Z"] },
" then ",
{ keys: ["Mod", "Shift", "Z"] },
]),
).toBe(`Press ${mod}+Z then ${mod}+Shift+Z`);
});
});
27 changes: 23 additions & 4 deletions packages/web/src/shortcuts/tips/shortcut-tips.data.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
import { expandModInShortcutDisplay } from "@web/shortcuts/shortcut.util";

export type ShortcutTipId =
| "edit-sequence"
| "nudge"
| "target-event"
| "edge-cycle";

export type ShortcutTipPart = string | { key: string };
export type ShortcutTipPart =
| string
| { key: string }
| { keys: readonly string[] };

export type ShortcutTip = {
id: ShortcutTipId;
parts: ShortcutTipPart[];
};

const spokenKey = (token: string): string => {
const expanded = expandModInShortcutDisplay(token);
if (expanded === "Meta") return "Cmd";
if (expanded === "Control") return "Ctrl";
return expanded;
};

const partPlainText = (part: ShortcutTipPart): string => {
if (typeof part === "string") return part;
const keys = "keys" in part ? part.keys : [part.key];
return keys.map(spokenKey).join("+");
};

export const getPartsPlainText = (parts: readonly ShortcutTipPart[]): string =>
parts.map(partPlainText).join("");

export const getTipPlainText = (tip: ShortcutTip): string =>
tip.parts
.map((part) => (typeof part === "string" ? part : part.key))
.join("");
getPartsPlainText(tip.parts);

/** Small fixed rotation; content mirrors the shortcut showcase's later lessons. */
export function getShortcutTips(): ShortcutTip[] {
Expand Down