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
4 changes: 2 additions & 2 deletions apps/web/src/decision-view-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function DecisionViewControl(
aria-current={view === "plan" ? "page" : undefined}
aria-label={view === "plan" ? undefined : documentActivityLabel(documentActivity)}
aria-pressed={view === "plan"}
className={`btn btn-sm relative transition-[background-color,box-shadow,color] ${
className={`btn btn-sm gap-1.5 transition-[background-color,box-shadow,color] ${
view === "plan"
? "bg-ground font-medium text-gray-800"
: "text-text-tertiary hover:bg-hover"
Expand All @@ -79,7 +79,7 @@ export function DecisionViewControl(
type="button"
>
Document
{view !== "plan" && <DocumentActivityDot activity={documentActivity} placement="corner" />}
{view !== "plan" && <DocumentActivityDot activity={documentActivity} />}
</button>
<button
aria-current={view === "decisions" ? "page" : undefined}
Expand Down
10 changes: 3 additions & 7 deletions apps/web/src/document-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,8 @@ test("labels name the activity for assistive technology", () => {
expect(documentActivityLabel(undefined)).toBe("Document");
});

test("the writing pulse only runs when motion is allowed", () => {
test("the document mark stays still while the Planner writes", () => {
let css = readFileSync(join(import.meta.dir, "theme.css"), "utf8");
let rule = /\.document-activity-dot\[data-document-activity="writing"\]\s*\{[^}]*animation:/;
let allowed = css.match(
/@media \(prefers-reduced-motion: no-preference\) \{\s*\.document-activity-dot[^]*?\n\}/,
);
expect(allowed?.[0]).toMatch(rule);
expect(css.replace(allowed![0], "")).not.toMatch(rule);
expect(css).not.toMatch(/\[data-document-activity[^{]*\{[^}]*animation:/);
expect(css).not.toMatch(/\.activity-dot[^{]*\{[^}]*animation:/);
});
8 changes: 3 additions & 5 deletions apps/web/src/document-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ export function documentActivityLabel(activity: DocumentActivity): string {
}

/**
* A quiet petrol mark: a breathing ring while the Planner writes, a filled dot
* once changes wait. `corner` sits in the button's padding so nothing shifts.
* A solid petrol dot, the same mark as Chat's unread badge, while the Planner writes or once
* changes wait. `inline` follows a label; `corner` sits on an icon, knocked out of its surface.
*/
export function DocumentActivityDot(
{ activity, placement = "inline" }: {
Expand All @@ -84,9 +84,7 @@ export function DocumentActivityDot(
return (
<span
aria-hidden="true"
className={`document-activity-dot shrink-0 rounded-full ${
activity === "writing" ? "size-2 border-2 border-brand" : "size-1.5 bg-brand"
} ${placement === "corner" ? "absolute right-0 top-1" : "ml-1"}`}
className={`activity-dot ${placement === "corner" ? "activity-badge" : ""}`}
data-document-activity={activity}
/>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/icon-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test("the sidebar owns the panel-close asset and the workspace uses line icons",
expect(sidebar).toContain("assets/icons/panel-close.svg");
expect(workspace).not.toContain("assets/icons/panel-close.svg");
expect(workspace).toContain("ExpandIcon");
expect(workspace).toContain("CollapseIcon");
expect(workspace).toContain("MessageIcon");
expect(existsSync(join(root, "assets/figma/navigation/collapse.svg"))).toBe(false);
expect(existsSync(join(root, "assets/icons/chat-close.svg"))).toBe(false);
});
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/phone-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -504,15 +504,19 @@
left: calc(100% - var(--spacing) * 2);
}

.phone-tab-dot {
/* The phone's Chat and Document marks: the shared activity badge, placed for a 24px glyph. */
.phone-tab-dot,
.phone-tab-icon > .activity-badge {
position: absolute;
top: 0;
right: calc(var(--spacing) * -0.5);
width: calc(var(--spacing) * 2);
height: calc(var(--spacing) * 2);
border: 2px solid var(--color-page);
border-radius: var(--radius-full);
background: var(--color-brand);
outline-style: solid;
outline-width: calc(var(--spacing) / 2);
outline-color: var(--color-page);
}

/* A single circular control at the end of the bar (`phoneAction`); slices add its contents. */
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/room-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,7 @@ export function RoomWorkspace(
title={
<DocumentTitle
{...titleProps}
icon
path={zoomChild}
/>
}
Expand Down
54 changes: 49 additions & 5 deletions apps/web/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,24 @@ body {
align-items: center;
}

/* After the Chat toggle's rule, the project sits as far in as the toggle's glyph sits from the edge. */
.workspace-surface-toggle-slot + .document-title-slot {
padding-inline-start: var(--control-padding-inline);
}

/* Both bars read at one size: the views' control text. */
.document-title-bar
:is(.document-project-prefix, .document-project-separator, .document-title-crumb, .title-control) {
font-size: var(--text-sm);
line-height: var(--text-sm--line-height);
}

/* The repository glyph sits as far from the project as the Chat toggle's glyph from its label. */
.document-title-bar .document-title > [data-nucleo-icon] {
margin-inline-end: calc(var(--spacing) / 2);
color: var(--color-text-tertiary);
}

.document-title-trailing {
display: flex;
align-self: stretch;
Expand Down Expand Up @@ -996,6 +1014,37 @@ body {
}
}

/* Unread and new-change marks: one solid petrol dot, still so it never competes with reading. */
.activity-dot {
display: block;
flex: none;
width: calc(var(--spacing) * 2);
height: calc(var(--spacing) * 2);
border-radius: var(--radius-full);
background: var(--color-brand);
}

/* On an icon's corner, a ring in the surface colour cuts the dot out of the glyph beneath. */
.activity-badge {
position: absolute;
top: calc(var(--spacing) * -0.75);
right: calc(var(--spacing) * -0.75);
outline-style: solid;
outline-width: calc(var(--spacing) / 2);
outline-color: var(--activity-badge-ring, var(--color-page));
}

/* The ring follows the ghost button's own fill so the knockout never shows as a halo. */
@media (hover: hover) {
.btn-ghost:hover:not(:disabled) {
--activity-badge-ring: var(--color-gray-200);
}
}

.btn-ghost:active:not(:disabled) {
--activity-badge-ring: var(--color-gray-300);
}

/* On the ground a white composer is the brightest thing in the column. */
.workspace-root .chat-composer .field {
background: var(--color-inset);
Expand Down Expand Up @@ -2057,11 +2106,6 @@ button.question-add:disabled {
border-radius: var(--radius-full);
}

@media (prefers-reduced-motion: no-preference) {
.document-activity-dot[data-document-activity="writing"] {
animation: chat-work-dot calc(var(--duration-base) * 8) var(--motion-move) infinite;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-work-lattice > span,
.chat-work-headline-motion,
Expand Down
22 changes: 10 additions & 12 deletions apps/web/src/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from
import { Count } from "@chopin/editor/count";
import { ContentSwapLayer } from "@chopin/editor/content-swap";
import { useTransitionPresence } from "@chopin/editor/transition-presence";
import { ArrowLeftIcon, ChevronIcon, CollapseIcon, ExpandIcon } from "@chopin/icons";
import { ArrowLeftIcon, ChevronIcon, ExpandIcon, MessageIcon } from "@chopin/icons";
import { usePhone } from "@chopin/editor/pointer";

import {
Expand Down Expand Up @@ -204,22 +204,20 @@ export function SurfaceToggle(
type="button"
>
<span
className={`${feedback} grid size-(--icon-size-default)`}
className={`${feedback} relative grid size-(--icon-size-default)`}
data-motion-feedback="icon"
key={expanded ? "restore" : "expand"}
>
{expanded ? <CollapseIcon size={14} /> : <ExpandIcon size={14} />}
{expanded ? <MessageIcon size={14} /> : <ExpandIcon size={14} />}
{expanded && status && (
<span
aria-hidden="true"
className={`activity-dot activity-badge ${unread ? feedback : ""}`}
data-motion-feedback={unread ? "count" : undefined}
/>
)}
</span>
{expanded && <span>Chat</span>}
{expanded && status && (
<span
aria-hidden="true"
className={`absolute right-1 top-1 size-1.5 rounded-full bg-brand ${
unread ? feedback : ""
}`}
data-motion-feedback={unread ? "count" : undefined}
/>
)}
</button>
);
}
Expand Down
9 changes: 9 additions & 0 deletions e2e/document-activity.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ for (

await expect(documentTab).toHaveAccessibleName("Document, new changes");
await expect(documentTab.locator("[data-document-activity='unseen']")).toBeVisible();
if (name === "desktop") {
// A still dot after the label, inside the tab and centred on its text.
let dot = documentTab.locator("[data-document-activity='unseen']");
await expect(dot).toHaveCSS("animation-name", "none");
let mark = (await dot.boundingBox())!;
let tab = (await documentTab.boundingBox())!;
expect(mark.x + mark.width).toBeLessThanOrEqual(tab.x + tab.width);
expect(Math.abs(mark.y + mark.height / 2 - (tab.y + tab.height / 2))).toBeLessThanOrEqual(1);
}

await documentTab.click();
await expect(content(page)).toContainText("Revised paragraph.");
Expand Down
4 changes: 4 additions & 0 deletions e2e/responsive-activity.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ test("a closed desktop Chat tab keeps unread activity visible", async ({ join, p
let toggle = page.getByRole("button", { name: "Show chat, 1 unread" });
await expect(toggle).toBeVisible();
await expect(toggle.locator('span[aria-hidden="true"]')).toHaveCount(1);
// The badge sits on the Chat glyph's corner, clear of the label.
let badge = (await toggle.locator('span[aria-hidden="true"]').boundingBox())!;
let label = (await toggle.getByText("Chat", { exact: true }).boundingBox())!;
expect(badge.x + badge.width).toBeLessThanOrEqual(label.x);
let count = toggle.locator('[data-motion-feedback="count"]');
expect(
await count.evaluate(element =>
Expand Down
2 changes: 1 addition & 1 deletion scripts/design-contract/exceptions/dynamic-web.json
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@
1
]
],
"sourceHash": "f6dc937a28e2d6641497848821c5fae44a597375191207598dfba0fa58fea5ee"
"sourceHash": "6dcdd2c5d6243bdc297c311742fc47e8e5da433d201b6e1b728c42c714d9a9c4"
},
{
"file": "apps/web/src/navigation-dialog.tsx",
Expand Down
Loading