diff --git a/apps/web/src/features/resume/stylesheet/legacy-banner.tsx b/apps/web/src/features/resume/stylesheet/legacy-banner.tsx index 3584575e2..764112be9 100644 --- a/apps/web/src/features/resume/stylesheet/legacy-banner.tsx +++ b/apps/web/src/features/resume/stylesheet/legacy-banner.tsx @@ -27,17 +27,3 @@ export function LegacyStylesheetBanner({ disabled, onActivate }: LegacyStyleshee ); } - -export function SemanticStylesheetReadOnlyNotice() { - return ( - - - - Semantic styles remain active - - - This instance does not currently allow Semantic CSS editing. - - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/index.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/index.tsx index 6804a7d51..42bdaae61 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/index.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/index.tsx @@ -1,5 +1,4 @@ import type { RightSidebarSection } from "@/libs/resume/section"; -import { useRouteContext } from "@tanstack/react-router"; import { Fragment, useCallback, useRef } from "react"; import { match } from "ts-pattern"; import { Button } from "@reactive-resume/ui/components/button"; @@ -23,13 +22,13 @@ import { StatisticsSectionBuilder } from "./sections/statistics"; import { TemplateSectionBuilder } from "./sections/template"; import { TypographySectionBuilder } from "./sections/typography"; -function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: boolean) { +function getSectionComponent(type: RightSidebarSection) { return match(type) .with("template", () => ) .with("layout", () => ) .with("typography", () => ) .with("design", () => ) - .with("styles", () => ) + .with("styles", () => ) .with("page", () => ) .with("notes", () => ) .with("sharing", () => ) @@ -42,8 +41,6 @@ function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: bo export function BuilderSidebarRight() { const scrollAreaRef = useRef(null); - const context = useRouteContext({ strict: false }); - const semanticCssAuthoring = context.flags?.semanticCssAuthoring ?? false; return ( <> @@ -56,7 +53,7 @@ export function BuilderSidebarRight() { {rightSidebarSections.map((section) => ( - {getSectionComponent(section, semanticCssAuthoring)} + {getSectionComponent(section)} ))} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx index b8d288466..35ebfd314 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx @@ -1,43 +1,12 @@ // @vitest-environment happy-dom -import type { StyleRule } from "@reactive-resume/schema/resume/data"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { beforeAll, beforeEach, expect, it, vi } from "vitest"; import { i18n } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; -import { isValidElement } from "react"; - -const updateResumeData = vi.hoisted(() => vi.fn()); -const styleRules = vi.hoisted(() => [ - { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { color: "rgba(220, 38, 38, 1)" } }, - }, -]); - -type SectionBaseProps = { - children: React.ReactNode; -}; vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children }: SectionBaseProps) => {children}, -})); - -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentResume: () => ({ - data: { - metadata: { styleRules }, - sections: { - experience: { title: "Experience" }, - skills: { title: "Skills" }, - }, - customSections: [{ id: "custom-1", title: "Open Source", type: "projects" }], - }, - }), - useUpdateResumeData: () => updateResumeData, + SectionBase: ({ children }: { children: React.ReactNode }) => {children}, })); vi.mock("@/features/resume/stylesheet/editor", () => ({ @@ -45,439 +14,27 @@ vi.mock("@/features/resume/stylesheet/editor", () => ({ })); const { CustomStylesSectionBuilder } = await import("./custom-styles"); -const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section"); -const { useStylesheetStore } = await import("@/features/resume/stylesheet/store"); +const { useSectionStore } = await import("../../../-store/section"); beforeAll(() => { i18n.loadAndActivate({ locale: "en", messages: {} }); }); beforeEach(() => { - updateResumeData.mockClear(); - useStylesheetStore.setState({ mode: "legacy" }); + useSectionStore.setState((state) => ({ + sections: { + ...state.sections, + styles: { ...state.sections.styles, collapsed: false }, + }, + })); }); -const renderCustomStyles = (authoringEnabled = false) => +it("always loads the Semantic CSS shell", async () => { render( - + , ); -const chooseComboboxOption = async (label: string, option: string) => { - fireEvent.click(screen.getByLabelText(label)); - fireEvent.click(await screen.findByRole("option", { name: option })); -}; - -describe("CustomStylesSectionBuilder", () => { - beforeEach(() => { - styleRules.splice(0, styleRules.length); - styleRules.push({ - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { color: "rgba(220, 38, 38, 1)" } }, - }); - }); - - it("loads the Semantic CSS shell only when authoring is enabled", async () => { - renderCustomStyles(true); - - expect(await screen.findByTestId("semantic-css-editor-shell")).toBeInTheDocument(); - expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument(); - }); - - it("shows a read-only notice for active Semantic CSS when authoring is disabled", () => { - useStylesheetStore.setState({ mode: "semantic" }); - renderCustomStyles(); - - expect(screen.getByText(/semantic styles remain active/i)).toBeInTheDocument(); - expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument(); - }); - - it("renders structured style rule controls", async () => { - renderCustomStyles(); - - expect(screen.getByLabelText("Target Scope")).toBeInTheDocument(); - expect(screen.getByLabelText("Style Slot")).toBeInTheDocument(); - expect(screen.getByLabelText("Text Color")).toBeInTheDocument(); - expect(screen.getByLabelText("Text Color").parentElement).toHaveClass("gap-3"); - expect(screen.getByLabelText("Text Color").parentElement?.parentElement?.parentElement).toHaveClass( - "grid-cols-1", - "@min-[20rem]:grid-cols-2", - "@min-[35rem]:grid-cols-4", - ); - expect(screen.getByLabelText("Text Color").parentElement?.parentElement?.parentElement).not.toHaveClass( - "grid-cols-[repeat(auto-fit,minmax(8rem,1fr))]", - ); - expect(screen.getByLabelText("Text Decoration Color")).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Color" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Text" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Spacing" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Border" })).toBeInTheDocument(); - expect(screen.getByLabelText("Font Style")).toBeInTheDocument(); - expect(screen.getByLabelText("Line Height")).toBeInTheDocument(); - expect(screen.getByLabelText("Letter Spacing")).toBeInTheDocument(); - expect(screen.getByLabelText("Text Decoration")).toBeInTheDocument(); - expect(screen.getByLabelText("Decoration Style")).toBeInTheDocument(); - expect(screen.getByLabelText("Text Align")).toBeInTheDocument(); - expect(screen.getByLabelText("Text Transform")).toBeInTheDocument(); - expect(screen.getByLabelText("Opacity")).toBeInTheDocument(); - expect(screen.getByText("Padding")).toBeInTheDocument(); - expect(screen.getByLabelText("Margin Top")).toBeInTheDocument(); - expect(screen.getByLabelText("Margin Right")).toBeInTheDocument(); - expect(screen.getByLabelText("Margin Bottom")).toBeInTheDocument(); - expect(screen.getByLabelText("Margin Left")).toBeInTheDocument(); - expect(screen.getByLabelText("Row Gap")).toBeInTheDocument(); - expect(screen.getByLabelText("Column Gap")).toBeInTheDocument(); - expect(screen.getByLabelText("Border Style")).toBeInTheDocument(); - expect(screen.getByLabelText("Border Width").parentElement?.parentElement).toHaveClass( - "grid-cols-1", - "@min-[20rem]:grid-cols-2", - "@min-[35rem]:grid-cols-4", - ); - fireEvent.click(screen.getByLabelText("Style Slot")); - expect(await screen.findByText("Section")).toBeInTheDocument(); - expect(screen.getByText("Rich text")).toBeInTheDocument(); - expect(await screen.findByRole("option", { name: "Section heading" })).toBeInTheDocument(); - expect(screen.getByRole("option", { name: "List" })).toBeInTheDocument(); - expect(screen.getByRole("option", { name: "List item content" })).toBeInTheDocument(); - expect(screen.queryByRole("option", { name: "Bullet or number" })).not.toBeInTheDocument(); - }); - - it("labels the empty font weight option as default", () => { - renderCustomStyles(); - - expect(screen.getByLabelText("Font Weight")).toHaveTextContent("Default"); - expect(screen.queryByText("Template default")).not.toBeInTheDocument(); - }); - - it("renames the sidebar entry and uses a distinct icon from design", () => { - const designIcon = getSectionIcon("design"); - const stylesIcon = getSectionIcon("styles"); - - expect(getSectionTitle("styles")).toBe("Custom Styles"); - expect(isValidElement(designIcon)).toBe(true); - expect(isValidElement(stylesIcon)).toBe(true); - expect(isValidElement(designIcon) && isValidElement(stylesIcon) && designIcon.type !== stylesIcon.type).toBe(true); - }); - - it("upserts one style rule for the selected target and slot", () => { - styleRules.splice(0, styleRules.length); - renderCustomStyles(); - - fireEvent.change(screen.getByLabelText("Text Color"), { target: { value: "rgba(220, 38, 38, 1)" } }); - - expect(updateResumeData).toHaveBeenCalledTimes(1); - const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void; - const draft = { metadata: { styleRules: [] } }; - recipe(draft); - - expect(draft.metadata.styleRules).toEqual([ - { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { color: "rgba(220, 38, 38, 1)" } }, - }, - ]); - }); - - it("stores padding as per-side values", () => { - styleRules.splice(0, styleRules.length); - renderCustomStyles(); - - expect(screen.getByText("Padding")).toBeInTheDocument(); - expect(screen.getByText("Padding")).toHaveClass("shrink-0"); - expect(screen.getByText("Padding").parentElement).toHaveClass("flex"); - expect(screen.queryByText("Padding Top")).not.toBeInTheDocument(); - expect(screen.getByLabelText("Padding Top")).toBeInTheDocument(); - expect(screen.getByLabelText("Padding Right")).toBeInTheDocument(); - expect(screen.getByLabelText("Padding Bottom")).toBeInTheDocument(); - expect(screen.getByLabelText("Padding Left")).toBeInTheDocument(); - expect(screen.getByLabelText("Padding Top")).toHaveAttribute("placeholder", "top"); - expect(screen.getByLabelText("Padding Right")).toHaveClass("text-center", "tabular-nums"); - - fireEvent.change(screen.getByLabelText("Padding Top"), { target: { value: "12" } }); - - expect(updateResumeData).toHaveBeenCalledTimes(1); - const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void; - const draft = { metadata: { styleRules: [] } }; - recipe(draft); - - expect(draft.metadata.styleRules).toEqual([ - { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { paddingTop: 12 } }, - }, - ]); - }); - - it("stores text decoration intent", async () => { - styleRules.splice(0, styleRules.length); - renderCustomStyles(); - - await chooseComboboxOption("Text Decoration", "Underline"); - - expect(updateResumeData).toHaveBeenCalledTimes(1); - const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void; - const draft = { metadata: { styleRules: [] } }; - recipe(draft); - - expect(draft.metadata.styleRules).toEqual([ - { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { textDecoration: "underline" } }, - }, - ]); - }); - - it("stores margin and gap intent", () => { - styleRules.splice(0, styleRules.length); - renderCustomStyles(); - - expect(screen.getByText("Margin")).toBeInTheDocument(); - expect(screen.getByText("Margin")).toHaveClass("shrink-0"); - expect(screen.queryByText("Margin Bottom")).not.toBeInTheDocument(); - expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("min", "-72"); - expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("placeholder", "bottom"); - expect(screen.getByText("Gap")).toBeInTheDocument(); - expect(screen.queryByText("Row Gap")).not.toBeInTheDocument(); - expect(screen.getByLabelText("Row Gap")).toHaveAttribute("min", "-72"); - expect(screen.getByLabelText("Row Gap")).toHaveAttribute("placeholder", "row"); - - fireEvent.change(screen.getByLabelText("Margin Bottom"), { target: { value: "-10" } }); - fireEvent.change(screen.getByLabelText("Row Gap"), { target: { value: "-6" } }); - - expect(updateResumeData).toHaveBeenCalledTimes(2); - - const marginRecipe = updateResumeData.mock.calls[0]?.[0] as (draft: { - metadata: { styleRules: unknown[] }; - }) => void; - const marginDraft = { metadata: { styleRules: [] } }; - marginRecipe(marginDraft); - - expect(marginDraft.metadata.styleRules).toEqual([ - { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { marginBottom: -10 } }, - }, - ]); - - const gapRecipe = updateResumeData.mock.calls[1]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void; - const gapDraft = { metadata: { styleRules: [] } }; - gapRecipe(gapDraft); - - expect(gapDraft.metadata.styleRules).toEqual([ - { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { rowGap: -6 } }, - }, - ]); - }); - - it("clamps manually typed style values to the schema bounds", () => { - styleRules.splice(0, styleRules.length); - renderCustomStyles(); - - fireEvent.change(screen.getByLabelText("Margin Top"), { target: { value: "100" } }); - fireEvent.change(screen.getByLabelText("Font Size"), { target: { value: "2" } }); - - expect(updateResumeData).toHaveBeenCalledTimes(2); - - const marginRecipe = updateResumeData.mock.calls[0]?.[0] as (draft: { - metadata: { styleRules: unknown[] }; - }) => void; - const marginDraft = { metadata: { styleRules: [] } }; - marginRecipe(marginDraft); - - expect(marginDraft.metadata.styleRules).toEqual([ - { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { marginTop: 72 } }, - }, - ]); - - const fontSizeRecipe = updateResumeData.mock.calls[1]?.[0] as (draft: { - metadata: { styleRules: unknown[] }; - }) => void; - const fontSizeDraft = { metadata: { styleRules: [] } }; - fontSizeRecipe(fontSizeDraft); - - expect(fontSizeDraft.metadata.styleRules).toEqual([ - { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { fontSize: 6 } }, - }, - ]); - }); - - it("keeps intermediate numeric text while the input is focused", () => { - styleRules.splice(0, styleRules.length); - renderCustomStyles(); - - const fontSizeInput = screen.getByLabelText("Font Size"); - fireEvent.focus(fontSizeInput); - fireEvent.change(fontSizeInput, { target: { value: "1" } }); - - expect(fontSizeInput).toHaveValue(1); - - fireEvent.change(fontSizeInput, { target: { value: "12" } }); - expect(fontSizeInput).toHaveValue(12); - - fireEvent.blur(fontSizeInput); - expect(fontSizeInput).toHaveValue(12); - }); - - it("commits normalized legacy values when the input loses focus", () => { - styleRules[0] = { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { borderWidth: 100 } }, - }; - renderCustomStyles(); - - const borderWidthInput = screen.getByLabelText("Border Width"); - expect(borderWidthInput).toHaveValue(100); - - fireEvent.focus(borderWidthInput); - fireEvent.blur(borderWidthInput); - - expect(borderWidthInput).toHaveValue(24); - expect(updateResumeData).toHaveBeenCalledTimes(1); - - const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: StyleRule[] } }) => void; - const draft = { metadata: { styleRules: structuredClone(styleRules) } }; - recipe(draft); - - expect(draft.metadata.styleRules[0]?.slots.heading?.borderWidth).toBe(24); - }); - - it("stores list slot rules for rich text lists", async () => { - styleRules.splice(0, styleRules.length); - renderCustomStyles(); - - await chooseComboboxOption("Style Slot", "List"); - fireEvent.change(screen.getByLabelText("Row Gap"), { target: { value: "8" } }); - - expect(updateResumeData).toHaveBeenCalledTimes(1); - const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void; - const draft = { metadata: { styleRules: [] } }; - recipe(draft); - - expect(draft.metadata.styleRules).toEqual([ - { - id: "style-global-richList", - label: "All sections: List", - enabled: true, - target: { scope: "global" }, - slots: { richList: { rowGap: 8 } }, - }, - ]); - }); - - it("can reset the selected style rule", () => { - renderCustomStyles(); - - fireEvent.click(screen.getByRole("button", { name: "Reset Style" })); - - expect(updateResumeData).toHaveBeenCalledTimes(1); - const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { - metadata: { styleRules: { id: string }[] }; - }) => void; - const draft = { - metadata: { - styleRules: [ - { - id: "style-global-heading", - label: "All sections: Section heading", - enabled: true, - target: { scope: "global" }, - slots: { heading: { color: "rgba(0, 0, 0, 1)" } }, - }, - { - id: "style-global-section", - label: "All sections: Section container", - enabled: true, - target: { scope: "global" }, - slots: { section: { padding: 4 } }, - }, - ], - }, - }; - recipe(draft); - - expect(draft.metadata.styleRules.map((rule) => rule.id)).toEqual(["style-global-section"]); - }); - - it("lists applied style rules and toggles individual rules", () => { - styleRules.push({ - id: "style-global-section", - label: "All sections: Section container", - enabled: false, - target: { scope: "global" }, - slots: { section: { paddingTop: 4 } }, - }); - renderCustomStyles(); - - expect(screen.getByText("Applied Rules")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Manage Rules" })).not.toBeInTheDocument(); - expect(screen.queryByText("All sections: Section heading")).not.toBeInTheDocument(); - expect(screen.queryByText("Off")).not.toBeInTheDocument(); - expect(screen.getAllByText("All sections").length).toBeGreaterThan(0); - expect(screen.getAllByText("Section heading").length).toBeGreaterThan(0); - expect(screen.queryByRole("switch")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Enable All sections: Section container" })).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Disable All sections: Section heading" })); - - expect(updateResumeData).toHaveBeenCalledTimes(1); - const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { - metadata: { styleRules: { id: string; enabled: boolean }[] }; - }) => void; - const draft = { metadata: { styleRules: [{ id: "style-global-heading", enabled: true }] } }; - recipe(draft); - - expect(draft.metadata.styleRules[0]?.enabled).toBe(false); - }); - - it("loads a selected applied rule into the editor form", () => { - styleRules.push({ - id: "style-section-type-experience-richListItemContent", - label: "Experience: List item content", - enabled: true, - target: { scope: "sectionType", sectionType: "experience" }, - slots: { richListItemContent: { lineHeight: 1.4 } }, - }); - renderCustomStyles(); - - fireEvent.click(screen.getByRole("button", { name: "Edit Experience: List item content" })); - - expect(screen.getByLabelText("Target Scope")).toHaveTextContent("Section type"); - expect(screen.getByLabelText("Section Type")).toHaveTextContent("Experience"); - expect(screen.getByLabelText("Style Slot")).toHaveTextContent("List item content"); - expect(screen.getByLabelText("Line Height")).toHaveValue(1.4); - }); + expect(await screen.findByTestId("semantic-css-editor-shell")).toBeInTheDocument(); }); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx index 09c9d9453..4af8e5989 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx @@ -1,1110 +1,23 @@ -import type { - ResumeData, - StyleIntent, - StyleRule, - StyleRuleTarget, - StyleSlot, -} from "@reactive-resume/schema/resume/data"; -import type { ReactNode } from "react"; -import type { ComboboxOption } from "@/components/ui/combobox"; -import { Trans } from "@lingui/react/macro"; -import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react"; -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; -import { sectionTypeSchema } from "@reactive-resume/schema/resume/data"; -import { Button } from "@reactive-resume/ui/components/button"; -import { Input } from "@reactive-resume/ui/components/input"; -import { Label } from "@reactive-resume/ui/components/label"; -import { Separator } from "@reactive-resume/ui/components/separator"; -import { slugify } from "@reactive-resume/utils/string"; -import { cn } from "@reactive-resume/utils/style"; -import { ColorPicker } from "@/components/input/color-picker"; -import { Combobox } from "@/components/ui/combobox"; -import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { SemanticStylesheetReadOnlyNotice } from "@/features/resume/stylesheet/legacy-banner"; -import { useStylesheetStore } from "@/features/resume/stylesheet/store"; -import { getSectionTitle } from "@/libs/resume/section"; +import { lazy, Suspense } from "react"; import { useSectionStore } from "../../../-store/section"; import { SectionBase } from "../shared/section-base"; const StylesheetEditorShell = lazy(() => import("@/features/resume/stylesheet/editor")); -type TargetScope = StyleRuleTarget["scope"]; - -type StyleSlotOption = { - value: StyleSlot; - label: string; - group: "Section" | "Rich text"; -}; - -const targetScopeOptions: ComboboxOption[] = [ - { value: "global", label: "All sections" }, - { value: "sectionType", label: "Section type" }, - { value: "sectionId", label: "Specific section" }, -]; - -const styleSlotOptions: StyleSlotOption[] = [ - { value: "section", label: "Section container", group: "Section" }, - { value: "heading", label: "Section heading", group: "Section" }, - { value: "item", label: "Item container", group: "Section" }, - { value: "text", label: "Primary text", group: "Section" }, - { value: "secondaryText", label: "Secondary text", group: "Section" }, - { value: "link", label: "Link", group: "Section" }, - { value: "icon", label: "Icon", group: "Section" }, - { value: "level", label: "Level indicator", group: "Section" }, - { value: "richParagraph", label: "Paragraph", group: "Rich text" }, - { value: "richList", label: "List", group: "Rich text" }, - { value: "richListItemRow", label: "List item row", group: "Rich text" }, - { value: "richListItemContent", label: "List item content", group: "Rich text" }, - { value: "richLink", label: "Inline link", group: "Rich text" }, - { value: "richBold", label: "Bold text", group: "Rich text" }, - { value: "richMark", label: "Highlight", group: "Rich text" }, -]; - -const styleSlotComboboxOptions: ComboboxOption[] = styleSlotOptions.map((option) => ({ - value: option.value, - label: option.label, - group: option.group, - keywords: [option.group], -})); - -const fontWeightOptions = ["100", "200", "300", "400", "500", "600", "700", "800", "900"] as const; -const fontStyleOptions = [ - { value: "normal", label: "Normal" }, - { value: "italic", label: "Italic" }, -] as const satisfies readonly { value: NonNullable; label: string }[]; -const textDecorationOptions = [ - { value: "none", label: "None" }, - { value: "underline", label: "Underline" }, - { value: "line-through", label: "Line through" }, -] as const satisfies readonly { value: NonNullable; label: string }[]; -const textDecorationStyleOptions = [ - { value: "solid", label: "Solid" }, - { value: "dashed", label: "Dashed" }, - { value: "dotted", label: "Dotted" }, -] as const satisfies readonly { value: NonNullable; label: string }[]; -const textAlignOptions = [ - { value: "left", label: "Left" }, - { value: "center", label: "Center" }, - { value: "right", label: "Right" }, - { value: "justify", label: "Justify" }, -] as const satisfies readonly { value: NonNullable; label: string }[]; -const textTransformOptions = [ - { value: "none", label: "None" }, - { value: "uppercase", label: "Uppercase" }, - { value: "lowercase", label: "Lowercase" }, - { value: "capitalize", label: "Capitalize" }, -] as const satisfies readonly { value: NonNullable; label: string }[]; -const borderStyleOptions = [ - { value: "solid", label: "Solid" }, - { value: "dashed", label: "Dashed" }, - { value: "dotted", label: "Dotted" }, -] as const satisfies readonly { value: NonNullable; label: string }[]; - -const controlGridClassName = "grid grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-3"; -const exactFourControlGridClassName = "grid grid-cols-1 gap-3 @min-[20rem]:grid-cols-2 @min-[35rem]:grid-cols-4"; -const compactSpacingInputClassName = - "h-8 w-18 max-w-18 min-w-0 px-1.5 text-center text-xs tabular-nums placeholder:text-[0.68rem] placeholder:uppercase placeholder:tracking-wide"; - -export type CustomStylesSectionBuilderProps = { - authoringEnabled?: boolean; -}; - -export function CustomStylesSectionBuilder({ authoringEnabled = false }: CustomStylesSectionBuilderProps) { - const mode = useStylesheetStore((state) => state.mode); +export function CustomStylesSectionBuilder() { const collapsed = useSectionStore((state) => state.sections.styles?.collapsed ?? false); return ( - {authoringEnabled ? ( - collapsed ? null : ( - - } - > - - - ) - ) : mode === "semantic" ? ( - - ) : ( - + {collapsed ? null : ( + + } + > + + )} ); } - -function LegacyCustomStylesSectionForm() { - const resume = useCurrentResume(); - const data = resume.data; - const updateResumeData = useUpdateResumeData(); - const sectionOptions = useMemo[]>(() => getSectionIdOptions(data), [data]); - const sectionTypeOptions = useMemo[]>( - () => sectionTypeSchema.options.map((type) => ({ value: type, label: getSectionTitle(type) })), - [], - ); - const styleRules = data.metadata.styleRules ?? []; - - const [targetScope, setTargetScope] = useState("global"); - const [sectionType, setSectionType] = useState("summary"); - const [sectionId, setSectionId] = useState("summary"); - const [slot, setSlot] = useState("heading"); - - const target = createTarget({ targetScope, sectionType, sectionId }); - const ruleId = getStyleRuleId(target, slot); - const currentRule = styleRules.find((rule) => rule.id === ruleId); - const currentIntent = currentRule?.slots[slot] ?? {}; - const targetLabel = getTargetLabel(data, target); - const slotLabel = getSlotLabel(slot); - - const upsertIntent = (patch: Partial) => { - const nextIntent = compactIntent({ ...currentIntent, ...patch }); - - updateResumeData((draft) => { - // Plain `?? ` assignment (not `??=`) so React Compiler can memoize this component; - // the compiler bails on logical-assignment operators today. Behavior is identical. - draft.metadata.styleRules = draft.metadata.styleRules ?? []; - const rules = draft.metadata.styleRules; - const existingIndex = rules.findIndex((rule) => rule.id === ruleId); - const existingRule = rules[existingIndex]; - - if (Object.keys(nextIntent).length === 0) { - if (existingIndex >= 0) rules.splice(existingIndex, 1); - return; - } - - const nextRule: StyleRule = { - id: ruleId, - label: existingRule?.label || `${targetLabel}: ${slotLabel}`, - enabled: existingRule?.enabled ?? true, - target, - slots: { [slot]: nextIntent }, - }; - - if (existingIndex >= 0) rules[existingIndex] = nextRule; - else rules.push(nextRule); - }); - }; - - const resetRule = () => { - updateResumeData((draft) => { - draft.metadata.styleRules = (draft.metadata.styleRules ?? []).filter((rule) => rule.id !== ruleId); - }); - }; - - const updateRuleEnabled = (ruleId: string, enabled: boolean) => { - updateResumeData((draft) => { - const rule = (draft.metadata.styleRules ?? []).find((rule) => rule.id === ruleId); - if (rule) rule.enabled = enabled; - }); - }; - - const editRule = (rule: StyleRule) => { - const nextSlot = getConfiguredSlots(rule)[0]; - if (!nextSlot) return; - - setTargetScope(rule.target.scope); - if (rule.target.scope === "sectionType") setSectionType(rule.target.sectionType); - if (rule.target.scope === "sectionId") setSectionId(rule.target.sectionId); - setSlot(nextSlot); - }; - - const deleteRule = (ruleId: string) => { - updateResumeData((draft) => { - draft.metadata.styleRules = (draft.metadata.styleRules ?? []).filter((rule) => rule.id !== ruleId); - }); - }; - - return ( - - - - { - if (value) setTargetScope(value); - }} - className="w-full" - placeholder="Target scope" - searchPlaceholder="Search scopes..." - /> - - - {targetScope === "sectionType" && ( - - { - if (value) setSectionType(value); - }} - className="w-full" - placeholder="Section type" - searchPlaceholder="Search section types..." - /> - - )} - - {targetScope === "sectionId" && ( - - { - if (value) setSectionId(value); - }} - className="w-full" - placeholder="Section" - searchPlaceholder="Search sections..." - /> - - )} - - - { - if (value) setSlot(value); - }} - className="w-full" - placeholder="Style slot" - searchPlaceholder="Search style slots..." - /> - - - - - - - - - - Reset Style - - - - - - - - ); -} - -type FieldProps = { - label: string; - id: string; - children: ReactNode; -}; - -function Field({ label, id, children }: FieldProps) { - return ( - - - {label} - - {children} - - ); -} - -type ColorFieldProps = { - label: string; - id: string; - value: string | undefined; - placeholder?: string; - fallback: string; - onChange: (value: string | undefined) => void; -}; - -function ColorField({ label, id, value, placeholder, fallback, onChange }: ColorFieldProps) { - return ( - - - onChange(color)} /> - onChange(event.target.value.trim() || undefined)} - /> - - - ); -} - -type NumberInputProps = { - label: string; - id?: string; - value: number | undefined; - min: number; - max: number; - step?: number; - onChange: (value: number | undefined) => void; -}; - -function parseBoundedNumberInput(value: string, min: number, max: number): number | undefined { - if (value === "") return undefined; - - const number = Number(value); - if (!Number.isFinite(number)) return undefined; - - return Math.min(max, Math.max(min, number)); -} - -function useBoundedNumberInput( - value: number | undefined, - min: number, - max: number, - onChange: (value: number | undefined) => void, -) { - const [inputValue, setInputValue] = useState(value?.toString() ?? ""); - const isFocused = useRef(false); - - useEffect(() => { - if (!isFocused.current) setInputValue(value?.toString() ?? ""); - }, [value]); - - return { - inputValue, - onFocus: () => { - isFocused.current = true; - }, - onBlur: () => { - isFocused.current = false; - const normalizedValue = parseBoundedNumberInput(inputValue, min, max); - setInputValue(normalizedValue?.toString() ?? ""); - if (normalizedValue !== value) onChange(normalizedValue); - }, - onInputChange: (nextValue: string) => { - setInputValue(nextValue); - onChange(parseBoundedNumberInput(nextValue, min, max)); - }, - }; -} - -function NumberInput({ label, id, value, min, max, step = 1, onChange }: NumberInputProps) { - const inputId = id ?? `style-${label.toLowerCase().replaceAll(" ", "-")}`; - const boundedInput = useBoundedNumberInput(value, min, max, onChange); - - return ( - - boundedInput.onInputChange(event.target.value)} - /> - - ); -} - -type AppliedRulesListProps = { - data: ResumeData; - rules: StyleRule[]; - onToggleRule: (ruleId: string, enabled: boolean) => void; - onEditRule: (rule: StyleRule) => void; - onDeleteRule: (ruleId: string) => void; -}; - -function AppliedRulesList({ data, rules, onToggleRule, onEditRule, onDeleteRule }: AppliedRulesListProps) { - return ( - - - - Applied Rules - - - {rules.length} {rules.length === 1 ? rule : rules} - - - - {rules.length === 0 ? ( - - No style rules yet. - - ) : ( - - {rules.map((rule) => ( - - ))} - - )} - - ); -} - -type AppliedRuleCardProps = { - data: ResumeData; - rule: StyleRule; - onToggleRule: (ruleId: string, enabled: boolean) => void; - onEditRule: (rule: StyleRule) => void; - onDeleteRule: (ruleId: string) => void; -}; - -function AppliedRuleCard({ data, rule, onToggleRule, onEditRule, onDeleteRule }: AppliedRuleCardProps) { - const slots = getConfiguredSlots(rule); - const primaryIntent = slots[0] ? rule.slots[slots[0]] : undefined; - const fallbackLabel = getRuleFallbackLabel(data, rule); - const ruleLabel = rule.label || fallbackLabel; - const targetLabel = getTargetLabel(data, rule.target); - const slotLabel = slots.length > 0 ? slots.map(getSlotLabel).join(", ") : "No slot"; - - return ( - - - - - - - - {primaryIntent && } - - - - onToggleRule(rule.id, !rule.enabled)} - > - {rule.enabled ? : } - - onEditRule(rule)} - > - - - onDeleteRule(rule.id)} - > - - - - - - ); -} - -type RuleScopePillProps = { - target: string; - slot: string; -}; - -function RuleScopePill({ target, slot }: RuleScopePillProps) { - return ( - - - {target} - - {slot} - - ); -} - -type RuleIntentEditorProps = { - idPrefix: string; - intent: StyleIntent; - onChange: (patch: Partial) => void; -}; - -function RuleIntentEditor({ idPrefix, intent, onChange }: RuleIntentEditorProps) { - return ( - - - - onChange({ color })} - /> - onChange({ backgroundColor })} - /> - onChange({ textDecorationColor })} - /> - onChange({ opacity })} - /> - - - - - - onChange({ fontSize })} - /> - onChange({ fontWeight })} - /> - onChange({ fontStyle })} - /> - onChange({ lineHeight })} - /> - onChange({ letterSpacing })} - /> - onChange({ textDecoration })} - /> - onChange({ textDecorationStyle })} - /> - onChange({ textAlign })} - /> - onChange({ textTransform })} - /> - - - - - - - - - onChange({ rowGap })} - /> - onChange({ columnGap })} - /> - - - - - - - onChange({ borderStyle })} - /> - onChange({ borderWidth })} - /> - onChange({ borderRadius })} - /> - onChange({ borderColor })} - /> - - - - ); -} - -type ControlPanelProps = { - title: string; - children: ReactNode; -}; - -function ControlPanel({ title, children }: ControlPanelProps) { - return ( - - {title} - {children} - - ); -} - -type IntentSelectFieldProps = { - label: string; - id: string; - value: TValue | undefined; - options: readonly ComboboxOption[]; - onChange: (value: TValue | undefined) => void; -}; - -function IntentSelectField({ - label, - id, - value, - options, - onChange, -}: IntentSelectFieldProps) { - return ( - - onChange(nextValue ?? undefined)} - className="w-full" - showClear - placeholder="Default" - searchPlaceholder={`Search ${label.toLowerCase()}...`} - /> - - ); -} - -type FontWeightFieldProps = { - label: string; - id: string; - value: StyleIntent["fontWeight"] | undefined; - onChange: (value: StyleIntent["fontWeight"] | undefined) => void; -}; - -function FontWeightField({ label, id, value, onChange }: FontWeightFieldProps) { - const options: ComboboxOption>[] = fontWeightOptions.map((weight) => ({ - value: weight, - label: weight, - })); - - return ( - - onChange(nextValue ?? undefined)} - className="w-full" - showClear - placeholder="Default" - searchPlaceholder="Search font weights..." - /> - - ); -} - -const paddingSideOptions = [ - { property: "paddingTop", label: "Top" }, - { property: "paddingRight", label: "Right" }, - { property: "paddingBottom", label: "Bottom" }, - { property: "paddingLeft", label: "Left" }, -] as const; - -type PaddingSideProperty = (typeof paddingSideOptions)[number]["property"]; - -const marginSideOptions = [ - { property: "marginTop", label: "Top" }, - { property: "marginRight", label: "Right" }, - { property: "marginBottom", label: "Bottom" }, - { property: "marginLeft", label: "Left" }, -] as const; - -type MarginSideProperty = (typeof marginSideOptions)[number]["property"]; - -type PaddingSideInputsProps = { - idPrefix: string; - intent: StyleIntent; - onChange: (patch: Partial) => void; -}; - -function PaddingSideInputs({ idPrefix, intent, onChange }: PaddingSideInputsProps) { - return ( - - {paddingSideOptions.map((side) => ( - onChange(createPaddingSidePatch(intent, side.property, value))} - /> - ))} - - ); -} - -type MarginSideInputsProps = { - idPrefix: string; - intent: StyleIntent; - onChange: (patch: Partial) => void; -}; - -function MarginSideInputs({ idPrefix, intent, onChange }: MarginSideInputsProps) { - return ( - - {marginSideOptions.map((side) => ( - onChange(createMarginSidePatch(side.property, value))} - /> - ))} - - ); -} - -type SpacingInputGroupProps = { - label: string; - children: ReactNode; -}; - -function SpacingInputGroup({ label, children }: SpacingInputGroupProps) { - return ( - - {label} - {children} - - ); -} - -type CompactNumberInputProps = { - ariaLabel: string; - id: string; - placeholder: string; - value: number | undefined; - min: number; - max: number; - step?: number; - onChange: (value: number | undefined) => void; -}; - -function CompactNumberInput({ - ariaLabel, - id, - placeholder, - value, - min, - max, - step = 1, - onChange, -}: CompactNumberInputProps) { - const boundedInput = useBoundedNumberInput(value, min, max, onChange); - - return ( - boundedInput.onInputChange(event.target.value)} - /> - ); -} - -function getPaddingSideValue(intent: StyleIntent, property: PaddingSideProperty) { - return intent[property] ?? intent.padding; -} - -function createPaddingSidePatch( - intent: StyleIntent, - property: PaddingSideProperty, - value: number | undefined, -): Partial { - if (intent.padding === undefined) return { [property]: value }; - - const patch: Partial = { padding: undefined }; - - for (const side of paddingSideOptions) { - patch[side.property] = intent[side.property] ?? intent.padding; - } - - patch[property] = value; - - return patch; -} - -function createMarginSidePatch(property: MarginSideProperty, value: number | undefined): Partial { - return { [property]: value }; -} - -type RulePropertySummaryProps = { - intent: StyleIntent; -}; - -function RulePropertySummary({ intent }: RulePropertySummaryProps) { - const properties = [ - intent.color && { label: "Text", value: intent.color, color: intent.color }, - intent.backgroundColor && { label: "Background", value: intent.backgroundColor, color: intent.backgroundColor }, - intent.textDecorationColor && { - label: "Decoration", - value: intent.textDecorationColor, - color: intent.textDecorationColor, - }, - intent.borderColor && { label: "Border", value: intent.borderColor, color: intent.borderColor }, - intent.opacity !== undefined && { label: "Opacity", value: `${intent.opacity}` }, - intent.fontSize && { label: "Size", value: `${intent.fontSize}` }, - intent.fontWeight && { label: "Weight", value: intent.fontWeight }, - intent.fontStyle && { label: "Style", value: intent.fontStyle }, - intent.lineHeight !== undefined && { label: "Line", value: `${intent.lineHeight}` }, - intent.letterSpacing !== undefined && { label: "Tracking", value: `${intent.letterSpacing}` }, - intent.textDecoration && { label: "Decoration", value: intent.textDecoration }, - intent.textDecorationStyle && { label: "Decoration Style", value: intent.textDecorationStyle }, - intent.textAlign && { label: "Align", value: intent.textAlign }, - intent.textTransform && { label: "Transform", value: intent.textTransform }, - getPaddingSummary(intent) && { label: "Padding", value: getPaddingSummary(intent) }, - getMarginSummary(intent) && { label: "Margin", value: getMarginSummary(intent) }, - getGapSummary(intent) && { label: "Gap", value: getGapSummary(intent) }, - intent.borderStyle && { label: "Border Style", value: intent.borderStyle }, - intent.borderWidth !== undefined && { label: "Border Width", value: `${intent.borderWidth}` }, - intent.borderRadius !== undefined && { label: "Radius", value: `${intent.borderRadius}` }, - ].filter(Boolean) as { label: string; value: string; color?: string }[]; - - if (properties.length === 0) return null; - - return ( - - {properties.map((property) => ( - - {property.color && ( - - )} - {property.label} - {property.value} - - ))} - - ); -} - -function getPaddingSummary(intent: StyleIntent) { - if (intent.padding !== undefined) return `All ${intent.padding}`; - - const sideValues = paddingSideOptions.flatMap((side) => { - const value = intent[side.property]; - if (value === undefined) return []; - - return [`${side.label.at(0)} ${value}`]; - }); - - return sideValues.length > 0 ? sideValues.join(" / ") : undefined; -} - -function getMarginSummary(intent: StyleIntent) { - const sideValues = marginSideOptions.flatMap((side) => { - const value = intent[side.property]; - if (value === undefined) return []; - - return [`${side.label.at(0)} ${value}`]; - }); - - return sideValues.length > 0 ? sideValues.join(" / ") : undefined; -} - -function getGapSummary(intent: StyleIntent) { - const values = [ - intent.rowGap !== undefined && `Row ${intent.rowGap}`, - intent.columnGap !== undefined && `Column ${intent.columnGap}`, - ].filter(Boolean); - - return values.length > 0 ? values.join(" / ") : undefined; -} - -type CreateTargetParams = { - targetScope: TargetScope; - sectionType: string; - sectionId: string; -}; - -function createTarget({ targetScope, sectionType, sectionId }: CreateTargetParams): StyleRuleTarget { - if (targetScope === "sectionType") { - return { - scope: "sectionType", - sectionType: sectionType as Extract["sectionType"], - }; - } - if (targetScope === "sectionId") return { scope: "sectionId", sectionId }; - - return { scope: "global" }; -} - -function getStyleRuleId(target: StyleRuleTarget, slot: StyleSlot) { - if (target.scope === "global") return `style-global-${slot}`; - if (target.scope === "sectionType") return `style-section-type-${target.sectionType}-${slot}`; - - return `style-section-id-${slugify(target.sectionId)}-${slot}`; -} - -function getSlotLabel(slot: StyleSlot) { - return styleSlotOptions.find((option) => option.value === slot)?.label ?? slot; -} - -function getTargetLabel(data: ResumeData, target: StyleRuleTarget) { - if (target.scope === "global") return "All sections"; - if (target.scope === "sectionType") return getSectionTitle(target.sectionType); - - return getSectionIdOptions(data).find((option) => option.value === target.sectionId)?.label ?? target.sectionId; -} - -function getRuleFallbackLabel(data: ResumeData, rule: StyleRule) { - const slots = getConfiguredSlots(rule); - const slot = slots[0]; - return `${getTargetLabel(data, rule.target)}${slot ? `: ${getSlotLabel(slot)}` : ""}`; -} - -function getConfiguredSlots(rule: StyleRule): StyleSlot[] { - const slots: StyleSlot[] = []; - - for (const option of styleSlotOptions) { - if (hasIntent(rule.slots[option.value])) slots.push(option.value); - } - - return slots; -} - -function getSectionIdOptions(data: ResumeData) { - return [ - { value: "summary", label: data.summary?.title || getSectionTitle("summary") }, - ...Object.entries(data.sections).map(([section, value]) => ({ - value: section, - label: value.title || getSectionTitle(section as keyof ResumeData["sections"]), - })), - ...data.customSections.map((section) => ({ - value: section.id, - label: section.title || getSectionTitle(section.type), - })), - ]; -} - -function compactIntent(intent: Partial): StyleIntent { - return Object.fromEntries( - Object.entries(intent).filter(([, value]) => value !== undefined && value !== ""), - ) as StyleIntent; -} - -function hasIntent(intent: StyleIntent | undefined) { - return Boolean(intent && Object.keys(intent).length > 0); -} diff --git a/docs/changelog/index.mdx b/docs/changelog/index.mdx index 455e19dd5..60ad23f8c 100644 --- a/docs/changelog/index.mdx +++ b/docs/changelog/index.mdx @@ -17,7 +17,7 @@ rss: true - Added the Semantic CSS editor under **Design → Custom Styles**, with live status, error line and column numbers, "selector matches nothing" warnings, stylesheet undo and redo, **Reset to applied stylesheet**, **Copy stylesheet**, and a focus mode that opens full-width on mobile. [d2ffbf961](https://github.com/amruthpillai/reactive-resume/commit/d2ffbf961) - Resumes still using the previous Custom Styles form get a converted stylesheet draft to review; the original legacy rules stay active until you select **Activate Semantic CSS**, and remain available for rollback. [d2ffbf961](https://github.com/amruthpillai/reactive-resume/commit/d2ffbf961) - Added semantic selectors, attributes, and template-specific parts for all 15 templates, plus pagination and page-size controls (`break-inside`, `orphans`, `widows`, `-resume-min-presence-ahead`, `size`) and PDF-dimension media queries. [d2ffbf961](https://github.com/amruthpillai/reactive-resume/commit/d2ffbf961) -- Self-hosters control the rollout with the new `FLAG_SEMANTIC_CSS_AUTHORING` and `FLAG_SEMANTIC_CSS_DEFAULT` environment variables. [d2ffbf961](https://github.com/amruthpillai/reactive-resume/commit/d2ffbf961) +- New resumes start in Semantic CSS mode automatically, while existing legacy styles remain active until their converted draft is explicitly activated. - Invalid style intents are now filtered out instead of discarding the whole rule set, so one bad entry no longer drops your valid custom styles. [689e7e24d](https://github.com/amruthpillai/reactive-resume/commit/689e7e24d) - Custom style numeric inputs are clamped to their supported ranges. [2a0782517](https://github.com/amruthpillai/reactive-resume/commit/2a0782517) - Award titles can now be un-bolded through custom styles. [08d859010](https://github.com/amruthpillai/reactive-resume/commit/08d859010) diff --git a/docs/spec.json b/docs/spec.json index abe308883..0cdcbc856 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -9969,23 +9969,13 @@ "smtpEnabled": { "type": "boolean", "description": "Whether outbound email (SMTP) is configured on this instance." - }, - "semanticCssAuthoring": { - "type": "boolean", - "description": "Whether Semantic CSS authoring is enabled on this instance." - }, - "semanticCssDefault": { - "type": "boolean", - "description": "Whether new resumes start in Semantic CSS mode." } }, "required": [ "disableSignups", "disableEmailAuth", "showSponsors", - "smtpEnabled", - "semanticCssAuthoring", - "semanticCssDefault" + "smtpEnabled" ] } } diff --git a/packages/api/src/features/flags/router.ts b/packages/api/src/features/flags/router.ts index cd63b3756..d2a2eb49c 100644 --- a/packages/api/src/features/flags/router.ts +++ b/packages/api/src/features/flags/router.ts @@ -7,8 +7,6 @@ export type FeatureFlags = { disableEmailAuth: boolean; showSponsors: boolean; smtpEnabled: boolean; - semanticCssAuthoring: boolean; - semanticCssDefault: boolean; }; // Mirrors isSmtpEnabled() in packages/email/src/transport.ts (kept local to avoid an api -> email dependency). @@ -32,8 +30,6 @@ export const flagsRouter = { disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."), showSponsors: z.boolean().describe("Whether sponsor placements are shown on this instance."), smtpEnabled: z.boolean().describe("Whether outbound email (SMTP) is configured on this instance."), - semanticCssAuthoring: z.boolean().describe("Whether Semantic CSS authoring is enabled on this instance."), - semanticCssDefault: z.boolean().describe("Whether new resumes start in Semantic CSS mode."), }), ) .handler( @@ -42,8 +38,6 @@ export const flagsRouter = { disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH, showSponsors: env.FLAG_SHOW_SPONSORS, smtpEnabled: isSmtpEnabled(), - semanticCssAuthoring: env.FLAG_SEMANTIC_CSS_AUTHORING, - semanticCssDefault: env.FLAG_SEMANTIC_CSS_DEFAULT, }), ), }; diff --git a/packages/api/src/features/resume/crud.test.ts b/packages/api/src/features/resume/crud.test.ts index 53cd524e9..4ec0355ce 100644 --- a/packages/api/src/features/resume/crud.test.ts +++ b/packages/api/src/features/resume/crud.test.ts @@ -8,8 +8,6 @@ const mocks = vi.hoisted(() => ({ getById: vi.fn(), })); -vi.mock("@reactive-resume/env/server", () => ({ env: { FLAG_SEMANTIC_CSS_DEFAULT: false } })); - vi.mock("../../context", async () => { const { os } = await vi.importActual("@orpc/server"); return { diff --git a/packages/api/src/features/resume/crud.ts b/packages/api/src/features/resume/crud.ts index d82f42ee4..83c3a6456 100644 --- a/packages/api/src/features/resume/crud.ts +++ b/packages/api/src/features/resume/crud.ts @@ -1,4 +1,3 @@ -import { env } from "@reactive-resume/env/server"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string"; import { protectedProcedure } from "../../context"; @@ -74,7 +73,6 @@ export const crudRouter = { locale: context.locale, userId: context.user.id, data: createResumeData({ - semanticCssDefault: env.FLAG_SEMANTIC_CSS_DEFAULT, withSampleData: input.withSampleData, name: input.name, locale: context.locale, diff --git a/packages/api/src/features/resume/stylesheet-preservation.test.ts b/packages/api/src/features/resume/stylesheet-preservation.test.ts index a3a62a62e..a96488a97 100644 --- a/packages/api/src/features/resume/stylesheet-preservation.test.ts +++ b/packages/api/src/features/resume/stylesheet-preservation.test.ts @@ -95,19 +95,17 @@ describe("hasRenderDataChanged", () => { }); describe("createResumeData", () => { - it("seeds empty semantic source only for the default-enabled cohort", () => { - expect(createResumeData({ semanticCssDefault: true }).metadata.stylesheet).toEqual({ + it("always seeds an empty semantic stylesheet", () => { + expect(createResumeData({}).metadata.stylesheet).toEqual({ mode: "semantic", source: { languageVersion: 1, text: "@version 1;\n" }, applied: { languageVersion: 1, text: "@version 1;\n" }, }); - expect(createResumeData({ semanticCssDefault: false }).metadata.stylesheet).toBeUndefined(); }); it("clones normal and sample defaults instead of mutating shared data", () => { - const normal = createResumeData({ semanticCssDefault: false, locale: "de-DE" }); + const normal = createResumeData({ locale: "de-DE" }); const sample = createResumeData({ - semanticCssDefault: true, withSampleData: true, name: "Sample Person", locale: "de-DE", diff --git a/packages/api/src/features/resume/stylesheet-preservation.ts b/packages/api/src/features/resume/stylesheet-preservation.ts index b670800dc..bb60cea36 100644 --- a/packages/api/src/features/resume/stylesheet-preservation.ts +++ b/packages/api/src/features/resume/stylesheet-preservation.ts @@ -6,7 +6,6 @@ import { createSampleResumeData } from "@reactive-resume/schema/resume/sample"; import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet"; type CreateResumeDataOptions = { - semanticCssDefault: boolean; withSampleData?: boolean; name?: string; locale?: Locale; @@ -30,13 +29,11 @@ export function createResumeData(options: CreateResumeDataOptions): ResumeData { const data = structuredClone(options.withSampleData ? createSampleResumeData(options.name) : defaultResumeData); if (options.locale) data.metadata.page.locale = options.locale; - if (options.semanticCssDefault) { - data.metadata.stylesheet = { - mode: "semantic", - source: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE }, - applied: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE }, - }; - } + data.metadata.stylesheet = { + mode: "semantic", + source: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE }, + applied: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE }, + }; return data; } diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index b7391307d..cc179498e 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -83,8 +83,6 @@ export const env = createEnv({ FLAG_SHOW_SPONSORS: z.stringbool().default(false), FLAG_ALLOW_UNSAFE_AI_BASE_URL: z.stringbool().default(false), FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: z.stringbool().default(false), - FLAG_SEMANTIC_CSS_AUTHORING: z.stringbool().default(false), - FLAG_SEMANTIC_CSS_DEFAULT: z.stringbool().default(false), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/playwright.config.ts b/playwright.config.ts index e5320ff10..6cf0bb442 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,7 +3,6 @@ import { defineConfig, devices } from "@playwright/test"; const port = Number.parseInt(process.env.PORT ?? "3000", 10); const baseURL = process.env.APP_URL ?? `http://localhost:${port}`; const isCI = process.env.CI === "true" || process.env.CI === "1"; -const isSemanticCssAuthoringRun = process.env.FLAG_SEMANTIC_CSS_AUTHORING === "true"; export default defineConfig({ testDir: "./tests/e2e/specs", @@ -12,7 +11,7 @@ export default defineConfig({ retries: 0, // Semantic CSS acceptance includes deterministic PDF preflight and 15 visual renders. Keep it serial so // independent browser workers do not contend for the fixed production preflight deadline. - workers: isSemanticCssAuthoringRun ? 1 : isCI ? 2 : undefined, + workers: 1, timeout: 30_000, expect: { timeout: 10_000, diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 18e8ba520..d37c5af8c 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -26,50 +26,18 @@ Run tests: `APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm test:e2e` -## Semantic CSS flag matrix +## Semantic CSS -Run the ordinary suite with both Semantic CSS rollout flags disabled: +Run opt-in conversion, editing, conflict, last-valid, default-mode, and visual acceptance: ```bash -FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false \ - pnpm exec playwright test --grep-invert "@semantic-css" -``` - -Run opt-in conversion, editing, conflict, last-valid, and visual acceptance. With authoring enabled, the Playwright -configuration automatically uses one worker so deterministic heavy browser preflight and visual checks do not compete -for the fixed production five-second deadline: - -```bash -FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false \ - pnpm exec playwright test \ - tests/e2e/specs/semantic-css/legacy-conversion.spec.ts \ - tests/e2e/specs/semantic-css/invalid-last-valid.spec.ts \ - tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts \ - tests/e2e/specs/semantic-css/revision-conflict.spec.ts \ - tests/e2e/specs/semantic-css/template-visual.spec.ts -``` - -Verify the default-on state for newly created resumes: - -```bash -FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=true \ - pnpm exec playwright test tests/e2e/specs/semantic-css/default-mode.spec.ts -``` - -Verify dormant authoring and persisted semantic rendering: - -```bash -FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false \ - pnpm exec playwright test \ - tests/e2e/specs/semantic-css/dormant-mode.spec.ts \ - tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts +pnpm exec playwright test tests/e2e/specs/semantic-css ``` Linux/Chromium visual baselines are updated intentionally with: ```bash -FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false \ - pnpm exec playwright test tests/e2e/specs/semantic-css/template-visual.spec.ts \ +pnpm exec playwright test tests/e2e/specs/semantic-css/template-visual.spec.ts \ --project=chromium --update-snapshots ``` @@ -80,7 +48,6 @@ FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false \ - Builder basics edit and autosave persistence. - JSON export/import. - Public sharing for anonymous visitors. -- Semantic CSS rollout states, legacy conversion, last-valid recovery, portability, revision conflicts, and all-template - visual regression. +- Semantic CSS legacy conversion, last-valid recovery, portability, revision conflicts, and all-template visual regression. PDF, DOCX, OAuth, passkeys, 2FA, password reset, and AI flows are intentionally outside the initial PR gate. diff --git a/tests/e2e/fixtures/db.ts b/tests/e2e/fixtures/db.ts index bb8270ca7..5fdbc0613 100644 --- a/tests/e2e/fixtures/db.ts +++ b/tests/e2e/fixtures/db.ts @@ -120,6 +120,7 @@ export async function updateSemanticCssFixture( } if (update.legacyStyleRule) { const metadata = data.metadata as Record; + delete metadata.stylesheet; metadata.styleRules = structuredClone(legacyParityRules); } if (update.hidePicture) { diff --git a/tests/e2e/specs/semantic-css/default-mode.spec.ts b/tests/e2e/specs/semantic-css/default-mode.spec.ts index 369ef3b2b..11a93fcd0 100644 --- a/tests/e2e/specs/semantic-css/default-mode.spec.ts +++ b/tests/e2e/specs/semantic-css/default-mode.spec.ts @@ -1,9 +1,7 @@ import { createSemanticCssResume, readStylesheetSource } from "../../fixtures/semantic-css"; import { expect, test } from "../../fixtures/test"; -test("@semantic-css starts new resumes in semantic mode when default-on is enabled", async ({ - authPage: page, -}, testInfo) => { +test("@semantic-css starts new resumes in semantic mode", async ({ authPage: page }, testInfo) => { await createSemanticCssResume(page, testInfo); await expect(page.getByText("Converted stylesheet draft", { exact: true })).toHaveCount(0); diff --git a/tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts b/tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts deleted file mode 100644 index 01cd19143..000000000 --- a/tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { updateSemanticCssFixture } from "../../fixtures/db"; -import { createSampleResumeFromDashboard, openSidebarSection } from "../../fixtures/resume"; -import { resumeIdFromPage, waitForStablePreview } from "../../fixtures/semantic-css"; -import { expect, test } from "../../fixtures/test"; - -test("@semantic-css keeps persisted semantic rendering active when authoring is off", async ({ - authPage: page, -}, testInfo) => { - await createSampleResumeFromDashboard(page, testInfo); - const beforeCanvas = await waitForStablePreview(page); - const before = await beforeCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL()); - const source = { languageVersion: 1, text: "@version 1;\nname { color: #2563eb; font-size: 30pt; }\n" }; - await updateSemanticCssFixture(resumeIdFromPage(page), { - stylesheet: { mode: "semantic", source, applied: source }, - }); - await page.reload(); - - const afterCanvas = await waitForStablePreview(page); - await expect - .poll(() => afterCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())) - .not.toBe(before); - await waitForStablePreview(page); - await openSidebarSection(page, "Custom Styles"); - await expect(page.getByText("Semantic styles remain active", { exact: true })).toBeVisible(); - await expect( - page.getByText("This instance does not currently allow Semantic CSS editing.", { exact: true }), - ).toBeVisible(); - await expect(page.getByLabel("Target Scope")).toHaveCount(0); -}); diff --git a/tests/e2e/specs/semantic-css/dormant-mode.spec.ts b/tests/e2e/specs/semantic-css/preservation.spec.ts similarity index 66% rename from tests/e2e/specs/semantic-css/dormant-mode.spec.ts rename to tests/e2e/specs/semantic-css/preservation.spec.ts index a1e23e4e8..7967ed706 100644 --- a/tests/e2e/specs/semantic-css/dormant-mode.spec.ts +++ b/tests/e2e/specs/semantic-css/preservation.spec.ts @@ -1,18 +1,8 @@ import { readSemanticCssFixture, updateSemanticCssFixture } from "../../fixtures/db"; -import { createSampleResumeFromDashboard, openSidebarSection } from "../../fixtures/resume"; +import { createSampleResumeFromDashboard } from "../../fixtures/resume"; import { resumeIdFromPage } from "../../fixtures/semantic-css"; import { expect, test } from "../../fixtures/test"; -test("@semantic-css keeps the legacy editor available while both flags are off", async ({ - authPage: page, -}, testInfo) => { - await createSampleResumeFromDashboard(page, testInfo); - await openSidebarSection(page, "Custom Styles"); - - await expect(page.getByLabel("Target Scope")).toBeVisible(); - await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveCount(0); -}); - test("@semantic-css preserves a persisted stylesheet through an old-client resume update", async ({ authPage: page, }, testInfo) => { diff --git a/turbo.json b/turbo.json index 39ba8039c..2f228bcea 100644 --- a/turbo.json +++ b/turbo.json @@ -86,9 +86,7 @@ "FLAG_DISABLE_API_RATE_LIMIT", "FLAG_SHOW_SPONSORS", "FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI", - "FLAG_ALLOW_UNSAFE_AI_BASE_URL", - "FLAG_SEMANTIC_CSS_AUTHORING", - "FLAG_SEMANTIC_CSS_DEFAULT" + "FLAG_ALLOW_UNSAFE_AI_BASE_URL" ], "tasks": { "build": {
- {rules.length} {rules.length === 1 ? rule : rules} -