mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-10 14:35:56 +09:00
## Summary
Rebuilds Webstudio’s color system, migrates the entire Builder to it,
and introduces a complete dark theme.
A small set of theme colors and contrast controls now drives the
interface through relative CSS colors:
```text
Theme colors and contrast
↓
Light/dark derivation bounds
↓
Derived color families
↓
Reusable semantic colors
↓
Component composition
↓
Builder interface
```
CSS is the runtime source of truth. Light and dark modes share the same
theme inputs, so changing a theme color automatically updates its
surfaces, content, borders, states, and component presentation. The
refactor turns dark mode into a first-class Builder theme rather than a
separately maintained palette.
This is an atomic migration. Legacy color names and compatibility
aliases will not remain after all consumers have moved.
## Scope
- The color source, derivation model, semantic vocabulary, validation,
typing, and Storybook overview.
- Every design-system component.
- Builder, dashboard, authentication, shared UI, Query Builder, stories,
and tests that consume the legacy color scale.
- Removal of the legacy Stitches color scale after its final consumer is
migrated.
- A complete dark theme across application UI while keeping authored
canvas content independent.
- Complete light and dark visual verification.
- Unified browser-test infrastructure: Vitest Browser Mode for Design
System browser contracts and standard Playwright Test for all Builder
E2E workflows.
Literal colors remain only where color is the actual content or belongs
to a deliberately separate system, such as user-authored values,
external previews, brand artwork, data visualization, functional canvas
overlays, and source assets. Every remaining exception must have an
explicit owner.
## Architecture decisions
- Theme authors control six color families and three bounded contrast
controls.
- Relative CSS colors derive the rest of the system.
- Dark mode changes derivation bounds instead of maintaining a second
independent palette.
- Components consume a small reusable semantic vocabulary.
- Component states and exceptional presentation are composed inside
components.
- Builder-specific categorical meaning uses focused composite recipes,
not intent semantics or a larger global vocabulary.
- TypeScript validates public semantic CSS-variable names but does not
generate runtime colors.
- No token-manager dependency, compatibility aliases, or
component-specific public color tokens.
- A new semantic or theme variable is added only when existing semantics
and local composition cannot represent a reusable cross-feature
decision.
## Todo
### Color-system foundation
- [x] Establish direct CSS as the runtime source of truth.
- [x] Define bounded theme colors and contrast controls.
- [x] Derive light and dark modes from the same theme inputs.
- [x] Reduce the semantic inventory to reusable decisions and explicit
accessibility pairs.
- [x] Generate only TypeScript names for public semantic CSS variables.
- [x] Validate layers, references, cycles, consumption, naming,
generated types, and browser-computed contrast.
- [x] Add the Storybook color-system overview and theme controls.
- [x] Document the architecture and its relationship to the public Craft
specification.
### Design-system migration
- [x] Replace every active legacy color consumer in design-system
components and Storybook chrome.
- [x] Compose component states locally without component-specific public
tokens.
- [x] Preserve intentional preview and sample-content colors.
- [x] Add complete positive, negative, warning, informative, and accent
accessibility pairings.
- [x] Validate light and dark scheme-bound references independently.
- [x] Verify the design-system package with typecheck, lint, formatting,
Storybook, and 222 tests.
### Inventory and contract
- [x] Inventory all 319 legacy references across 107 Builder, Query
Builder, story, test, and design-system files.
- [x] Classify every reference as a direct semantic replacement, local
composite recipe, documented literal exception, or removable code.
- [x] Record the intentional authored-content, external-preview,
functional-preview, data-visualization, brand, and source-asset
exceptions.
- [x] Treat the public semantic vocabulary as closed by default and
justify any proposed addition by cross-feature reuse and accessibility
need.
- [x] Keep Builder code limited to typed public variables accepted by
`cssVar()`.
### Theme infrastructure
- [x] Add `system` (default), `light`, and `dark` application
preferences.
- [x] Persist the preference in existing client settings and resolve
`system` through `matchMedia`.
- [x] Apply the resolved scheme before paint and respond to
operating-system changes.
- [x] Expose the persisted theme control under **View → Color scheme**.
- [x] Keep authored canvas documents independent from the application
theme.
- [x] Ensure portals, overlays, embedded surfaces, and third-party
controls receive the correct interface scheme.
### Generic interface migration
- [x] Migrate shared dialogs, project settings, notifications, controls,
and Query Builder.
- [x] Migrate dashboard, authentication, publishing, and shared
application UI.
- [x] Migrate the Builder loading state, topbar, sidebars, navigator,
workspace, footer, settings, pages, alerts, and menus.
- [x] Replace fixed interface literals alongside their owning surfaces.
- [x] Remove the old scrollbar variable and hardcoded loading color.
### Style panel and style-source recipes
- [x] Migrate ordinary style-panel inputs, labels, layouts, spacing,
alignment, menus, property states, and value editors.
- [x] Model style-source provenance, kind, interaction, and error as
separate axes.
- [x] Compose categorical style-source colors from neutral and
accent-relative colors.
- [x] Use negative semantics only for actual errors, not overwritten
provenance.
- [x] Reuse one feature-owned recipe across style-source controls,
badges, menus, and values without adding global tokens.
### Canvas and editors
- [x] Migrate canvas selection, reusable outlines, grid guides, resize
handles, labels, badges, and overlays.
- [x] Keep collaborator colors as data visualization while guaranteeing
readable labels.
- [x] Prevent Builder theme values from leaking into authored content.
- [x] Migrate editor surfaces, selections, autocomplete, lint states,
and tooltips.
- [x] Derive syntax visualization from neutral and accent-relative
colors; reserve intent colors for diagnostics.
### Explicit color domains
- [x] Isolate external search and social previews from the application
scheme.
- [x] Preserve user-authored CSS values, functional color previews, and
checkerboards.
- [x] Review brand surfaces independently in both schemes.
- [x] Document every retained literal in the exception ledger.
### Stories, tests, and legacy removal
- [x] Migrate all affected Builder stories and tests.
- [x] Replace the design-system test that intentionally references the
legacy scale.
- [x] Add theme-preference and scheme-resolution behavior tests.
- [x] Remove the legacy `design-tokens.color` registry.
- [x] Remove the Stitches `colors` scale and `rawTheme.colors`.
- [x] Remove color-name conversion code that exists only for the legacy
scale.
- [x] Add a focused repository check that rejects legacy references.
- [x] Confirm no compatibility mappings or deprecated aliases remain.
### Browser-test infrastructure
- [x] Run pure Design System tests in Node and browser-dependent tests
in Vitest Browser Mode with Playwright Chromium.
- [x] Migrate all 79 Builder E2E workflows from the custom harness to
standard Playwright Test.
- [x] Replace filename-based shard discovery with standard Playwright
sharding.
- [x] Preserve clean-backend shard retries and upload Playwright failure
artifacts in CI.
- [x] Remove the custom runner, registry, and timeout infrastructure.
### Verification and documentation
- [x] Compare light-mode visual regressions directly with `main`; review
every difference rather than accepting snapshots wholesale.
- [x] Add representative dark coverage for dashboard, Builder shell,
style panel, dialogs, editors, canvas tools, and status states.
- [x] Test default and extreme custom-theme presets.
- [x] Verify semantic and categorical-recipe contrast.
- [x] Test first paint, persistence, system changes, portals, and canvas
isolation.
- [x] Run affected tests, typechecks, lint, formatting, and Storybook
checks.
- [x] Review fixture impact; no fixture inputs or generated outputs are
affected.
- [x] Review authoritative `docs/` content and update it where needed.
- [x] Review the final diff and make this checklist reflect delivered
scope.
## Current verification
- Design-system typecheck and generated-type freshness: passed.
- Design-system tests: 222 passed, including the full theme-parameter
contrast sweep and browser component coverage.
- Builder typecheck and production build: passed.
- Theme preference and scheme-resolution tests: 10 passed.
- Scoped Oxlint, formatting, and diff checks: passed.
- Design-system Storybook build and representative light/dark visual
review: passed.
- Builder E2E: 80/80 Playwright entries passed (setup plus 79
workflows).
Ref #3818
Closes #6267
234 lines
6.8 KiB
TypeScript
234 lines
6.8 KiB
TypeScript
import {
|
|
Flex,
|
|
Grid,
|
|
List,
|
|
ListItem,
|
|
Text,
|
|
rawTheme,
|
|
theme,
|
|
ToggleGroup,
|
|
ToggleGroupButton,
|
|
PanelBanner,
|
|
panelBannerIconColor,
|
|
LinkButton,
|
|
} from "@webstudio-is/design-system";
|
|
import { RepeatGridIcon, ListViewIcon } from "@webstudio-is/icons";
|
|
import type { DashboardProject } from "@webstudio-is/dashboard";
|
|
import type { WorkspaceWithRelation } from "@webstudio-is/project";
|
|
import { ProjectCard } from "./project-card";
|
|
import { CreateProject } from "./project-dialogs";
|
|
import { Header, Main } from "../shared/layout";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { setIsSubsetOf } from "~/shared/shim";
|
|
import { useStore } from "@nanostores/react";
|
|
import type { User } from "~/shared/db/user.server";
|
|
import { $permissions } from "~/shared/nano-states";
|
|
import { Tag } from "./tags";
|
|
import {
|
|
SortSelect,
|
|
sortProjects,
|
|
type SortState,
|
|
type SortField,
|
|
} from "./sort";
|
|
import { AlertIcon } from "@webstudio-is/icons";
|
|
import { getSeatSuspendedMessage } from "~/shared/notifications/seat-suspended";
|
|
import { ProjectsList } from "./projects-list";
|
|
|
|
export const ProjectsGrid = ({
|
|
projects,
|
|
publisherHost,
|
|
projectsTags,
|
|
}: ProjectsProps) => {
|
|
return (
|
|
<List asChild>
|
|
<Grid
|
|
gap="6"
|
|
css={{
|
|
gridTemplateColumns: `repeat(auto-fill, minmax(${rawTheme.spacing[31]}, 1fr))`,
|
|
paddingBottom: theme.spacing[13],
|
|
width: "100%",
|
|
}}
|
|
>
|
|
{projects.map((project) => {
|
|
return (
|
|
<ListItem index={0} key={project.id} asChild>
|
|
<ProjectCard
|
|
project={project}
|
|
publisherHost={publisherHost}
|
|
projectsTags={projectsTags}
|
|
/>
|
|
</ListItem>
|
|
);
|
|
})}
|
|
</Grid>
|
|
</List>
|
|
);
|
|
};
|
|
|
|
type ProjectsProps = {
|
|
projects: Array<DashboardProject>;
|
|
publisherHost: string;
|
|
projectsTags: User["projectsTags"];
|
|
currentWorkspaceId?: string;
|
|
workspace?: WorkspaceWithRelation;
|
|
isWorkspaceSuspended?: boolean;
|
|
};
|
|
|
|
export const Projects = (props: ProjectsProps) => {
|
|
const permissions = useStore($permissions);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const selectedTags = searchParams.getAll("tag");
|
|
const viewMode = (searchParams.get("view") as "grid" | "list") ?? "grid";
|
|
|
|
const sortState: SortState = {
|
|
sortBy: searchParams.get("sortBy") as SortState["sortBy"],
|
|
order: searchParams.get("order") as SortState["order"],
|
|
};
|
|
|
|
const handleSortChange = (newSortState: Required<SortState>) => {
|
|
const newParams = new URLSearchParams(searchParams);
|
|
newParams.set("sortBy", newSortState.sortBy);
|
|
newParams.set("order", newSortState.order);
|
|
setSearchParams(newParams);
|
|
};
|
|
|
|
const handleTableSort = (field: SortField) => {
|
|
const currentSortBy = sortState.sortBy ?? "updatedAt";
|
|
const currentOrder = sortState.order ?? "desc";
|
|
|
|
// If clicking the same field, toggle the order
|
|
if (field === currentSortBy) {
|
|
const newOrder = currentOrder === "asc" ? "desc" : "asc";
|
|
handleSortChange({ sortBy: field, order: newOrder });
|
|
} else {
|
|
// When switching to a new field, use smart defaults
|
|
const newOrder = field === "title" ? "asc" : "desc";
|
|
handleSortChange({ sortBy: field, order: newOrder });
|
|
}
|
|
};
|
|
|
|
const handleViewChange = (value: string) => {
|
|
const newParams = new URLSearchParams(searchParams);
|
|
if (value === "grid") {
|
|
newParams.delete("view");
|
|
} else {
|
|
newParams.set("view", value);
|
|
}
|
|
setSearchParams(newParams);
|
|
};
|
|
|
|
// Filter by tags
|
|
let projects = props.projects;
|
|
if (selectedTags.length > 0) {
|
|
projects = projects.filter((project) =>
|
|
setIsSubsetOf(new Set(selectedTags), new Set(project.tags))
|
|
);
|
|
}
|
|
projects = sortProjects(projects, sortState);
|
|
|
|
return (
|
|
<Main>
|
|
<Header variant="main">
|
|
<Text variant="brandSectionTitle" as="h2">
|
|
Projects
|
|
</Text>
|
|
<Flex gap="2">
|
|
<ToggleGroup
|
|
type="single"
|
|
value={viewMode}
|
|
onValueChange={handleViewChange}
|
|
>
|
|
<ToggleGroupButton value="grid" aria-label="Grid view">
|
|
<RepeatGridIcon />
|
|
</ToggleGroupButton>
|
|
<ToggleGroupButton value="list" aria-label="List view">
|
|
<ListViewIcon />
|
|
</ToggleGroupButton>
|
|
</ToggleGroup>
|
|
<SortSelect value={sortState} onValueChange={handleSortChange} />
|
|
<LinkButton
|
|
href="https://webstudio.is/marketplace/templates/"
|
|
target="_blank"
|
|
>
|
|
Use template
|
|
</LinkButton>
|
|
{permissions.canCreateProject && (
|
|
<CreateProject workspaceId={props.currentWorkspaceId} />
|
|
)}
|
|
</Flex>
|
|
</Header>
|
|
<Flex
|
|
gap="2"
|
|
shrink={false}
|
|
justify="between"
|
|
css={{
|
|
paddingInline: theme.spacing[13],
|
|
paddingBlockStart: theme.spacing[2],
|
|
paddingBlockEnd: theme.spacing[10],
|
|
}}
|
|
>
|
|
<Flex gap="2" wrap="wrap" align="center">
|
|
{props.projectsTags.map((tag, index) => {
|
|
return (
|
|
<Tag
|
|
tag={tag}
|
|
key={tag.id}
|
|
index={index}
|
|
pressed={selectedTags.includes(tag.id)}
|
|
>
|
|
{tag.label}
|
|
</Tag>
|
|
);
|
|
})}
|
|
</Flex>
|
|
</Flex>
|
|
<Flex css={{ paddingInline: theme.spacing[13] }}>
|
|
{projects.length === 0 ? (
|
|
props.isWorkspaceSuspended && props.workspace !== undefined ? (
|
|
<Flex
|
|
css={{
|
|
paddingBlock: theme.spacing[20],
|
|
flexGrow: 1,
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<PanelBanner variant="warning" css={{ maxWidth: 400 }}>
|
|
<Flex align="center" gap="1">
|
|
<AlertIcon color={panelBannerIconColor} />
|
|
<Text variant="regularBold">Workspace suspended</Text>
|
|
</Flex>
|
|
<Text variant="regular">
|
|
{getSeatSuspendedMessage(props.workspace.name)}
|
|
</Text>
|
|
</PanelBanner>
|
|
</Flex>
|
|
) : (
|
|
<Text
|
|
variant="brandRegular"
|
|
css={{
|
|
paddingBlock: theme.spacing[20],
|
|
textAlign: "center",
|
|
flexGrow: 1,
|
|
}}
|
|
>
|
|
No projects found
|
|
</Text>
|
|
)
|
|
) : viewMode === "grid" ? (
|
|
<ProjectsGrid {...props} projects={projects} />
|
|
) : (
|
|
<ProjectsList
|
|
{...props}
|
|
projects={projects}
|
|
sortBy={sortState.sortBy}
|
|
sortOrder={sortState.order}
|
|
onSortChange={handleTableSort}
|
|
/>
|
|
)}
|
|
</Flex>
|
|
</Main>
|
|
);
|
|
};
|
|
|
|
undefined;
|