diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bef01208..134c2b45 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,7 +166,7 @@ High-level responsibilities: - `dashboard`: role-specific metrics and dashboard data - `entitlements`: `deriveEntitlements()`, server enforcement, `useEntitlements()` hook - `interviews`: interview lifecycle, routes, agent chat hooks/components, server functions -- `jobs`: job CRUD, lifecycle, requirements, screening questions +- `jobs`: job CRUD, lifecycle, Markdown descriptions, screening questions - `notifications`: inbox UI, payload rendering, email delivery - `pre-evaluations`: pre-screening queries and server functions - `reports`: post-evaluation reports, report pages, reusable report components @@ -265,8 +265,7 @@ Source of truth: - owned by a company - stores: - - title, description - - requirements + - title and canonical Markdown description (including requirements/qualifications) - screening questions - lifecycle status (`draft`, `open`, `closed`) - salary / workplace / experience fields diff --git a/PLATFORM.md b/PLATFORM.md index 2ccc48f3..fced8179 100644 --- a/PLATFORM.md +++ b/PLATFORM.md @@ -316,8 +316,7 @@ Company profile should contain: Job should contain: - title -- description -- requirements +- Markdown description (including responsibilities and qualifications) - location - type - experience level diff --git a/app/components/editor/plugins/basic-blocks-kit.tsx b/app/components/editor/plugins/basic-blocks-kit.tsx new file mode 100644 index 00000000..b3d79531 --- /dev/null +++ b/app/components/editor/plugins/basic-blocks-kit.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { BlockquoteRules, HeadingRules, HorizontalRuleRules } from "@platejs/basic-nodes"; +import { + BlockquotePlugin, + H1Plugin, + H2Plugin, + H3Plugin, + H4Plugin, + H5Plugin, + H6Plugin, + HorizontalRulePlugin, +} from "@platejs/basic-nodes/react"; +import { ParagraphPlugin } from "platejs/react"; + +import { BlockquoteElement } from "@/components/ui/blockquote-node"; +import { + H1Element, + H2Element, + H3Element, + H4Element, + H5Element, + H6Element, +} from "@/components/ui/heading-node"; +import { HrElement } from "@/components/ui/hr-node"; +import { ParagraphElement } from "@/components/ui/paragraph-node"; + +export const BasicBlocksKit = [ + ParagraphPlugin.withComponent(ParagraphElement), + H1Plugin.configure({ + inputRules: [HeadingRules.markdown()], + node: { + component: H1Element, + }, + rules: { + break: { empty: "reset" }, + }, + shortcuts: { toggle: { keys: "mod+alt+1" } }, + }), + H2Plugin.configure({ + inputRules: [HeadingRules.markdown()], + node: { + component: H2Element, + }, + rules: { + break: { empty: "reset" }, + }, + shortcuts: { toggle: { keys: "mod+alt+2" } }, + }), + H3Plugin.configure({ + inputRules: [HeadingRules.markdown()], + node: { + component: H3Element, + }, + rules: { + break: { empty: "reset" }, + }, + shortcuts: { toggle: { keys: "mod+alt+3" } }, + }), + H4Plugin.configure({ + inputRules: [HeadingRules.markdown()], + node: { + component: H4Element, + }, + rules: { + break: { empty: "reset" }, + }, + shortcuts: { toggle: { keys: "mod+alt+4" } }, + }), + H5Plugin.configure({ + inputRules: [HeadingRules.markdown()], + node: { + component: H5Element, + }, + rules: { + break: { empty: "reset" }, + }, + shortcuts: { toggle: { keys: "mod+alt+5" } }, + }), + H6Plugin.configure({ + inputRules: [HeadingRules.markdown()], + node: { + component: H6Element, + }, + rules: { + break: { empty: "reset" }, + }, + shortcuts: { toggle: { keys: "mod+alt+6" } }, + }), + BlockquotePlugin.configure({ + inputRules: [BlockquoteRules.markdown()], + node: { component: BlockquoteElement }, + shortcuts: { toggle: { keys: "mod+shift+period" } }, + }), + HorizontalRulePlugin.configure({ + inputRules: [ + HorizontalRuleRules.markdown({ variant: "-" }), + HorizontalRuleRules.markdown({ variant: "_" }), + ], + node: { + component: HrElement, + }, + }), +]; diff --git a/app/components/editor/plugins/basic-marks-kit.tsx b/app/components/editor/plugins/basic-marks-kit.tsx new file mode 100644 index 00000000..86b8d3b9 --- /dev/null +++ b/app/components/editor/plugins/basic-marks-kit.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { BoldRules, CodeRules, ItalicRules, StrikethroughRules } from "@platejs/basic-nodes"; +import { + BoldPlugin, + CodePlugin, + ItalicPlugin, + StrikethroughPlugin, +} from "@platejs/basic-nodes/react"; + +import { CodeLeaf } from "@/components/ui/code-node"; + +export const BasicMarksKit = [ + BoldPlugin.configure({ + inputRules: [BoldRules.markdown({ variant: "*" }), BoldRules.markdown({ variant: "_" })], + }), + ItalicPlugin.configure({ + inputRules: [ItalicRules.markdown({ variant: "*" }), ItalicRules.markdown({ variant: "_" })], + }), + CodePlugin.configure({ + inputRules: [CodeRules.markdown()], + node: { component: CodeLeaf }, + shortcuts: { toggle: { keys: "mod+e" } }, + }), + StrikethroughPlugin.configure({ + inputRules: [StrikethroughRules.markdown()], + shortcuts: { toggle: { keys: "mod+shift+x" } }, + }), +]; diff --git a/app/components/editor/plugins/indent-kit.tsx b/app/components/editor/plugins/indent-kit.tsx new file mode 100644 index 00000000..58323ff5 --- /dev/null +++ b/app/components/editor/plugins/indent-kit.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { IndentPlugin } from "@platejs/indent/react"; +import { KEYS } from "platejs"; + +export const IndentKit = [ + IndentPlugin.configure({ + inject: { + targetPlugins: [ + ...KEYS.heading, + KEYS.p, + KEYS.blockquote, + KEYS.codeBlock, + KEYS.toggle, + KEYS.img, + ], + }, + options: { + offset: 24, + }, + }), +]; diff --git a/app/components/editor/plugins/link-kit.tsx b/app/components/editor/plugins/link-kit.tsx new file mode 100644 index 00000000..a560daff --- /dev/null +++ b/app/components/editor/plugins/link-kit.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { LinkRules } from "@platejs/link"; +import { LinkPlugin } from "@platejs/link/react"; + +import { LinkElement } from "@/components/ui/link-node"; +import { LinkFloatingToolbar } from "@/components/ui/link-toolbar"; + +export const LinkKit = [ + LinkPlugin.configure({ + inputRules: [ + LinkRules.markdown(), + LinkRules.autolink({ variant: "paste" }), + LinkRules.autolink({ variant: "space" }), + LinkRules.autolink({ variant: "break" }), + ], + render: { + node: LinkElement, + afterEditable: () => , + }, + }), +]; diff --git a/app/components/editor/plugins/list-kit.tsx b/app/components/editor/plugins/list-kit.tsx new file mode 100644 index 00000000..08e48eb2 --- /dev/null +++ b/app/components/editor/plugins/list-kit.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { BulletedListRules, isOrderedList, OrderedListRules } from "@platejs/list"; +import { ListPlugin } from "@platejs/list/react"; +import { KEYS } from "platejs"; + +import { IndentKit } from "@/components/editor/plugins/indent-kit"; +import { BlockList } from "@/components/ui/block-list"; + +export const ListKit = [ + ...IndentKit, + ListPlugin.configure({ + inputRules: [ + BulletedListRules.markdown({ variant: "-" }), + BulletedListRules.markdown({ variant: "*" }), + OrderedListRules.markdown({ variant: "." }), + OrderedListRules.markdown({ variant: ")" }), + ], + inject: { + nodeProps: { + nodeKey: KEYS.listType, + query: ({ nodeProps }) => { + const element = nodeProps.element; + + return !!element?.listStyleType && !isOrderedList(element); + }, + transformProps: ({ props }) => ({ + ...props, + role: "listitem", + style: Object.assign({}, props.style, { display: "list-item" }), + }), + }, + targetPlugins: [ + ...KEYS.heading, + KEYS.p, + KEYS.blockquote, + KEYS.codeBlock, + KEYS.toggle, + KEYS.img, + ], + }, + render: { + belowNodes: BlockList, + }, + }), +]; diff --git a/app/components/ui/block-list.tsx b/app/components/ui/block-list.tsx new file mode 100644 index 00000000..ea22fd7f --- /dev/null +++ b/app/components/ui/block-list.tsx @@ -0,0 +1,34 @@ +'use client'; + +import type { TListElement } from 'platejs'; +import type { ReactNode } from 'react'; + +import { isOrderedList } from '@platejs/list'; +import { + type PlateElementProps, + type RenderNodeWrapper, +} from 'platejs/react'; + +export const BlockList: RenderNodeWrapper = (props) => { + if (!props.element.listStyleType) return; + if (!isOrderedList(props.element)) return; + + return (props) => ; +}; + +function List(props: PlateElementProps & { lineBreakBadge?: ReactNode }) { + const { listStart, listStyleType } = props.element as TListElement; + + return ( +
    +
  1. + {props.children} + {props.lineBreakBadge} +
  2. +
+ ); +} diff --git a/app/components/ui/blockquote-node.tsx b/app/components/ui/blockquote-node.tsx new file mode 100644 index 00000000..ba5bec4e --- /dev/null +++ b/app/components/ui/blockquote-node.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { type PlateElementProps, PlateElement } from 'platejs/react'; + +export function BlockquoteElement(props: PlateElementProps) { + return ( + + ); +} diff --git a/app/components/ui/code-node.tsx b/app/components/ui/code-node.tsx new file mode 100644 index 00000000..db23d7b3 --- /dev/null +++ b/app/components/ui/code-node.tsx @@ -0,0 +1,17 @@ +'use client'; + +import type { PlateLeafProps } from 'platejs/react'; + +import { PlateLeaf } from 'platejs/react'; + +export function CodeLeaf(props: PlateLeafProps) { + return ( + + {props.children} + + ); +} diff --git a/app/components/ui/editor.tsx b/app/components/ui/editor.tsx new file mode 100644 index 00000000..7a4e2402 --- /dev/null +++ b/app/components/ui/editor.tsx @@ -0,0 +1,132 @@ +'use client'; + +import * as React from 'react'; + +import type { VariantProps } from 'class-variance-authority'; +import type { PlateContentProps, PlateViewProps } from 'platejs/react'; + +import { cva } from 'class-variance-authority'; +import { PlateContainer, PlateContent, PlateView } from 'platejs/react'; + +import { cn } from '@/lib/utils'; + +const editorContainerVariants = cva( + 'relative w-full cursor-text select-text overflow-y-auto caret-primary selection:bg-brand/25 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15', + { + defaultVariants: { + variant: 'default', + }, + variants: { + variant: { + comment: cn( + 'flex flex-wrap justify-between gap-1 px-1 py-0.5 text-sm', + 'rounded-md border-[1.5px] border-transparent bg-transparent', + 'has-[[data-slate-editor]:focus]:border-brand/50 has-[[data-slate-editor]:focus]:ring-2 has-[[data-slate-editor]:focus]:ring-brand/30', + 'has-aria-disabled:border-input has-aria-disabled:bg-muted' + ), + default: 'h-full', + demo: 'h-[650px]', + select: cn( + 'group rounded-md border border-input ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2', + 'has-data-readonly:w-fit has-data-readonly:cursor-default has-data-readonly:border-transparent has-data-readonly:focus-within:[box-shadow:none]' + ), + }, + }, + } +); + +export function EditorContainer({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( + + ); +} + +const editorVariants = cva( + cn( + 'group/editor', + 'relative w-full cursor-text select-text overflow-x-hidden whitespace-break-spaces break-words', + 'rounded-md ring-offset-background focus-visible:outline-none', + '**:data-slate-placeholder:!top-1/2 **:data-slate-placeholder:-translate-y-1/2 placeholder:text-muted-foreground/80 **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!', + '[&_strong]:font-bold' + ), + { + defaultVariants: { + variant: 'default', + }, + variants: { + disabled: { + true: 'cursor-not-allowed opacity-50', + }, + focused: { + true: 'ring-2 ring-ring ring-offset-2', + }, + variant: { + ai: 'w-full px-0 text-base md:text-sm', + aiChat: + 'max-h-[min(70vh,320px)] w-full overflow-y-auto px-3 py-2 text-base md:text-sm', + comment: cn('rounded-none border-none bg-transparent text-sm'), + default: + 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]', + demo: 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]', + fullWidth: 'size-full px-16 pt-4 pb-72 text-base sm:px-24', + none: '', + select: 'px-3 py-2 text-base data-readonly:w-fit', + }, + }, + } +); + +export type EditorProps = PlateContentProps & + VariantProps; + +export const Editor = ({ + className, + disabled, + focused, + variant, + ref, + ...props +}: EditorProps & { ref?: React.RefObject }) => ( + +); + +Editor.displayName = 'Editor'; + +export function EditorView({ + className, + variant, + ...props +}: PlateViewProps & VariantProps) { + return ( + + ); +} + +EditorView.displayName = 'EditorView'; diff --git a/app/components/ui/heading-node.tsx b/app/components/ui/heading-node.tsx new file mode 100644 index 00000000..ff661d64 --- /dev/null +++ b/app/components/ui/heading-node.tsx @@ -0,0 +1,61 @@ +'use client'; + +import type { PlateElementProps } from 'platejs/react'; + +import { type VariantProps, cva } from 'class-variance-authority'; +import { PlateElement } from 'platejs/react'; + +const headingVariants = cva( + 'relative mb-1 data-[nav-target=true]:rounded-md data-[nav-target=true]:bg-(--color-highlight)', + { + variants: { + variant: { + h1: 'mt-[1.6em] pb-1 font-bold font-heading text-4xl', + h2: 'mt-[1.4em] pb-px font-heading font-semibold text-2xl tracking-tight', + h3: 'mt-[1em] pb-px font-heading font-semibold text-xl tracking-tight', + h4: 'mt-[0.75em] font-heading font-semibold text-lg tracking-tight', + h5: 'mt-[0.75em] font-semibold text-lg tracking-tight', + h6: 'mt-[0.75em] font-semibold text-base tracking-tight', + }, + }, + } +); + +export function HeadingElement({ + variant = 'h1', + ...props +}: PlateElementProps & VariantProps) { + return ( + + {props.children} + + ); +} + +export function H1Element(props: PlateElementProps) { + return ; +} + +export function H2Element(props: PlateElementProps) { + return ; +} + +export function H3Element(props: PlateElementProps) { + return ; +} + +export function H4Element(props: PlateElementProps) { + return ; +} + +export function H5Element(props: PlateElementProps) { + return ; +} + +export function H6Element(props: PlateElementProps) { + return ; +} diff --git a/app/components/ui/history-toolbar-button.tsx b/app/components/ui/history-toolbar-button.tsx new file mode 100644 index 00000000..dbf99a49 --- /dev/null +++ b/app/components/ui/history-toolbar-button.tsx @@ -0,0 +1,57 @@ +'use client'; + +import * as React from 'react'; + +import { Redo02Icon, Undo02Icon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { useEditorRef, useEditorSelector } from 'platejs/react'; + +import { ToolbarButton } from './toolbar'; + +export function RedoToolbarButton( + props: React.ComponentProps +) { + const editor = useEditorRef(); + const disabled = useEditorSelector( + (editor) => editor.history.redos.length === 0, + [] + ); + const onClick = () => editor.redo(); + const onMouseDown = (event: React.MouseEvent) => event.preventDefault(); + + return ( + + + + ); +} + +export function UndoToolbarButton( + props: React.ComponentProps +) { + const editor = useEditorRef(); + const disabled = useEditorSelector( + (editor) => editor.history.undos.length === 0, + [] + ); + const onClick = () => editor.undo(); + const onMouseDown = (event: React.MouseEvent) => event.preventDefault(); + + return ( + + + + ); +} diff --git a/app/components/ui/hr-node.tsx b/app/components/ui/hr-node.tsx new file mode 100644 index 00000000..a733450b --- /dev/null +++ b/app/components/ui/hr-node.tsx @@ -0,0 +1,33 @@ +'use client'; + +import type { PlateElementProps } from 'platejs/react'; + +import { + PlateElement, + useFocused, + useReadOnly, + useSelected, +} from 'platejs/react'; + +import { cn } from '@/lib/utils'; + +export function HrElement(props: PlateElementProps) { + const readOnly = useReadOnly(); + const selected = useSelected(); + const focused = useFocused(); + + return ( + +
+
+
+ {props.children} +
+ ); +} diff --git a/app/components/ui/link-node.tsx b/app/components/ui/link-node.tsx new file mode 100644 index 00000000..9a8c0ae8 --- /dev/null +++ b/app/components/ui/link-node.tsx @@ -0,0 +1,26 @@ +'use client'; + +import type { TLinkElement } from 'platejs'; +import type { PlateElementProps } from 'platejs/react'; +import type { MouseEvent } from 'react'; + +import { getLinkAttributes } from '@platejs/link'; +import { PlateElement } from 'platejs/react'; + +export function LinkElement(props: PlateElementProps) { + const onMouseOver = (event: MouseEvent) => event.stopPropagation(); + return ( + + {props.children} + + ); +} diff --git a/app/components/ui/link-toolbar-button.tsx b/app/components/ui/link-toolbar-button.tsx new file mode 100644 index 00000000..4d27793f --- /dev/null +++ b/app/components/ui/link-toolbar-button.tsx @@ -0,0 +1,25 @@ +'use client'; + +import * as React from 'react'; + +import { + useLinkToolbarButton, + useLinkToolbarButtonState, +} from '@platejs/link/react'; +import { Link01Icon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; + +import { ToolbarButton } from './toolbar'; + +export function LinkToolbarButton( + props: React.ComponentProps +) { + const state = useLinkToolbarButtonState(); + const { props: buttonProps } = useLinkToolbarButton(state); + + return ( + + + + ); +} diff --git a/app/components/ui/link-toolbar.tsx b/app/components/ui/link-toolbar.tsx new file mode 100644 index 00000000..fa50ef01 --- /dev/null +++ b/app/components/ui/link-toolbar.tsx @@ -0,0 +1,199 @@ +'use client'; + +import * as React from 'react'; + +import type { TLinkElement } from 'platejs'; + +import { + ExternalLinkIcon, + Link01Icon, + TextIcon, + Unlink01Icon, +} from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { + type UseVirtualFloatingOptions, + flip, + offset, +} from '@platejs/floating'; +import { getLinkAttributes } from '@platejs/link'; +import { + type LinkFloatingToolbarState, + FloatingLinkUrlInput, + useFloatingLinkEdit, + useFloatingLinkEditState, + useFloatingLinkInsert, + useFloatingLinkInsertState, +} from '@platejs/link/react'; +import { cva } from 'class-variance-authority'; +import { KEYS } from 'platejs'; +import { + useEditorRef, + useEditorSelection, + useFormInputProps, + usePluginOption, +} from 'platejs/react'; + +import { Button, buttonVariants } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; + +const popoverVariants = cva( + 'z-50 w-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden' +); + +const inputVariants = cva( + 'flex h-[28px] w-full rounded-md border-none bg-transparent px-1.5 py-1 text-base placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-transparent md:text-sm' +); + +export function LinkFloatingToolbar({ + state, +}: { + state?: LinkFloatingToolbarState; +}) { + const activeCommentId = usePluginOption({ key: KEYS.comment }, 'activeId'); + const activeSuggestionId = usePluginOption( + { key: KEYS.suggestion }, + 'activeId' + ); + + const floatingOptions: UseVirtualFloatingOptions = { + middleware: [ + offset(8), + flip({ + fallbackPlacements: ['bottom-end', 'top-start', 'top-end'], + padding: 12, + }), + ], + placement: + activeSuggestionId || activeCommentId ? 'top-start' : 'bottom-start', + }; + + const insertState = useFloatingLinkInsertState({ + ...state, + floatingOptions: { + ...floatingOptions, + ...state?.floatingOptions, + }, + }); + const { + hidden, + props: insertProps, + ref: insertRef, + textInputProps, + } = useFloatingLinkInsert(insertState); + + const editState = useFloatingLinkEditState({ + ...state, + floatingOptions: { + ...floatingOptions, + ...state?.floatingOptions, + }, + }); + const { + editButtonProps, + props: editProps, + ref: editRef, + unlinkButtonProps, + } = useFloatingLinkEdit(editState); + const inputProps = useFormInputProps({ + preventDefaultOnEnterKeydown: true, + }); + + if (hidden) return null; + + const input = ( +
+
+
+ +
+ + +
+ +
+
+ +
+ +
+
+ ); + + const editContent = editState.isEditing ? ( + input + ) : ( +
+ + + + + + + + + +
+ ); + + return ( + <> +
+ {input} +
+ +
+ {editContent} +
+ + ); +} + +function LinkOpenButton() { + const editor = useEditorRef(); + useEditorSelection(); + const entry = editor.api.node({ + match: { type: editor.getType(KEYS.link) }, + }); + const attributes = entry ? getLinkAttributes(editor, entry[0]) : {}; + const onMouseOver = (event: React.MouseEvent) => event.stopPropagation(); + + return ( + + + + ); +} diff --git a/app/components/ui/mark-toolbar-button.tsx b/app/components/ui/mark-toolbar-button.tsx new file mode 100644 index 00000000..1f940575 --- /dev/null +++ b/app/components/ui/mark-toolbar-button.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; + +import { useMarkToolbarButton, useMarkToolbarButtonState } from 'platejs/react'; + +import { ToolbarButton } from './toolbar'; + +export function MarkToolbarButton({ + clear, + nodeType, + ...props +}: React.ComponentProps & { + nodeType: string; + clear?: string[] | string; +}) { + const state = useMarkToolbarButtonState({ clear, nodeType }); + const { props: buttonProps } = useMarkToolbarButton(state); + + return ; +} diff --git a/app/components/ui/paragraph-node.tsx b/app/components/ui/paragraph-node.tsx new file mode 100644 index 00000000..d7d8ea1b --- /dev/null +++ b/app/components/ui/paragraph-node.tsx @@ -0,0 +1,13 @@ +'use client'; + +import type { PlateElementProps } from 'platejs/react'; + +import { PlateElement } from 'platejs/react'; + +export function ParagraphElement(props: PlateElementProps) { + return ( + + {props.children} + + ); +} diff --git a/app/components/ui/toolbar.tsx b/app/components/ui/toolbar.tsx new file mode 100644 index 00000000..c77066f0 --- /dev/null +++ b/app/components/ui/toolbar.tsx @@ -0,0 +1,396 @@ +'use client'; + +import * as React from 'react'; + +import * as ToolbarPrimitive from '@radix-ui/react-toolbar'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { ArrowDown01Icon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { type VariantProps, cva } from 'class-variance-authority'; + +import { + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; +import { Separator } from '@/components/ui/separator'; +import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +export function Toolbar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export function ToolbarToggleGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export function ToolbarLink({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export function ToolbarSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +// From toggleVariants +const toolbarButtonVariants = cva( + "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-checked:bg-accent aria-checked:text-accent-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + defaultVariants: { + size: 'default', + variant: 'default', + }, + variants: { + size: { + default: 'h-9 min-w-9 px-2', + lg: 'h-10 min-w-10 px-2.5', + sm: 'h-8 min-w-8 px-1.5', + }, + variant: { + default: 'bg-transparent', + outline: + 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', + }, + }, + } +); + +const dropdownArrowVariants = cva( + cn( + 'inline-flex items-center justify-center rounded-r-md font-medium text-foreground text-sm transition-colors disabled:pointer-events-none disabled:opacity-50' + ), + { + defaultVariants: { + size: 'sm', + variant: 'default', + }, + variants: { + size: { + default: 'h-9 w-6', + lg: 'h-10 w-8', + sm: 'h-8 w-4', + }, + variant: { + default: + 'bg-transparent hover:bg-muted hover:text-muted-foreground aria-checked:bg-accent aria-checked:text-accent-foreground', + outline: + 'border border-input border-l-0 bg-transparent hover:bg-accent hover:text-accent-foreground', + }, + }, + } +); + +type ToolbarButtonProps = { + isDropdown?: boolean; + pressed?: boolean; +} & Omit< + React.ComponentPropsWithoutRef, + 'asChild' | 'value' +> & + VariantProps; + +export const ToolbarButton = withTooltip(function ToolbarButton({ + children, + className, + isDropdown, + pressed, + size = 'sm', + variant, + ...props +}: ToolbarButtonProps) { + return typeof pressed === 'boolean' ? ( + + + {isDropdown ? ( + <> +
+ {children} +
+
+ +
+ + ) : ( + children + )} +
+
+ ) : ( + + {children} + + ); +}); + +export function ToolbarSplitButton({ + className, + ...props +}: React.ComponentPropsWithoutRef) { + return ( + + ); +} + +type ToolbarSplitButtonPrimaryProps = Omit< + React.ComponentPropsWithoutRef, + 'value' +> & + VariantProps; + +export function ToolbarSplitButtonPrimary({ + children, + className, + size = 'sm', + variant, + ...props +}: ToolbarSplitButtonPrimaryProps) { + return ( + + {children} + + ); +} + +export function ToolbarSplitButtonSecondary({ + className, + size, + variant, + ...props +}: React.ComponentPropsWithoutRef<'span'> & + VariantProps) { + return ( + + + + ); +} + +export function ToolbarToggleItem({ + className, + size = 'sm', + variant, + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ); +} + +export function ToolbarGroup({ + children, + className, +}: React.ComponentProps<'div'>) { + return ( + + ); +} + +type TooltipProps = { + tooltip?: React.ReactNode; + tooltipContentProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' + >; + tooltipProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' + >; + tooltipTriggerProps?: React.ComponentPropsWithoutRef; +} & React.ComponentProps; + +function withTooltip(Component: T) { + return function ExtendComponent({ + tooltip, + tooltipContentProps, + tooltipProps, + tooltipTriggerProps, + ...props + }: TooltipProps) { + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + const component = )} />; + + if (tooltip && mounted) { + return ( + + + {component} + + + {tooltip} + + ); + } + + return component; + }; +} + +function TooltipContent({ + children, + className, + // CHANGE + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + {children} + {/* CHANGE */} + {/* */} + + + ); +} + +export function ToolbarMenuGroup({ + children, + className, + label, + ...props +}: React.ComponentProps & { label?: string }) { + return ( + <> + + + + {label && ( + + {label} + + )} + {children} + + + ); +} diff --git a/app/features/admin/__tests__/admin-metrics.test.ts b/app/features/admin/__tests__/admin-metrics.test.ts index 9017d6a9..77742105 100644 --- a/app/features/admin/__tests__/admin-metrics.test.ts +++ b/app/features/admin/__tests__/admin-metrics.test.ts @@ -28,7 +28,6 @@ describe("platform admin metrics queries", () => { companyId: company.id, title: "Platform Metrics Job", description: "Test", - requirements: [], screeningQuestions: [], status: "open", location: null, diff --git a/app/features/applications/__tests__/business-logic.test.ts b/app/features/applications/__tests__/business-logic.test.ts index 7e3d4e3e..1e0a8d5e 100644 --- a/app/features/applications/__tests__/business-logic.test.ts +++ b/app/features/applications/__tests__/business-logic.test.ts @@ -23,7 +23,6 @@ const makeOpenJob = async (companyId: string, title = "Open Job") => { companyId, title, description: "Test", - requirements: [], screeningQuestions: [], status: "open", location: null, diff --git a/app/features/applications/queries/__tests__/applications.test.ts b/app/features/applications/queries/__tests__/applications.test.ts index 19f3eed0..389347b7 100644 --- a/app/features/applications/queries/__tests__/applications.test.ts +++ b/app/features/applications/queries/__tests__/applications.test.ts @@ -28,7 +28,6 @@ const makeOpenJob = async (companyId: string, title = "Open Job") => { companyId, title, description: "Test", - requirements: [], screeningQuestions: [], status: "open", location: null, diff --git a/app/features/dashboard/__tests__/dashboard-sections.test.ts b/app/features/dashboard/__tests__/dashboard-sections.test.ts index 52fe7271..27bbbe96 100644 --- a/app/features/dashboard/__tests__/dashboard-sections.test.ts +++ b/app/features/dashboard/__tests__/dashboard-sections.test.ts @@ -117,7 +117,6 @@ describe("candidate dashboard recent activity expiry overlay", () => { companyId: company.id, title: "Expired Interview Role", description: "Test", - requirements: [], screeningQuestions: [], status: "open", location: null, diff --git a/app/features/dashboard/__tests__/dashboard.test.ts b/app/features/dashboard/__tests__/dashboard.test.ts index b3f687b7..9f80cccf 100644 --- a/app/features/dashboard/__tests__/dashboard.test.ts +++ b/app/features/dashboard/__tests__/dashboard.test.ts @@ -28,7 +28,6 @@ const makeOpenJob = async (companyId: string, title = "Open Job") => { companyId, title, description: "Test", - requirements: [], screeningQuestions: [], status: "open", location: null, @@ -51,7 +50,6 @@ const makeDraftJob = async (companyId: string, title = "Draft Job") => { companyId, title, description: "Test", - requirements: [], screeningQuestions: [], status: "draft", location: null, diff --git a/app/features/interviews/queries/__tests__/interviews.test.ts b/app/features/interviews/queries/__tests__/interviews.test.ts index 702145be..ac21e1d8 100644 --- a/app/features/interviews/queries/__tests__/interviews.test.ts +++ b/app/features/interviews/queries/__tests__/interviews.test.ts @@ -34,7 +34,6 @@ const makeApplication = async () => { company_id, title, description, - requirements, screening_questions, status, final_report_target @@ -42,8 +41,7 @@ const makeApplication = async () => { VALUES ( ${company.id}, ${"Platform Engineer"}, - ${"Build APIs and data workflows"}, - ${["TypeScript", "Postgres"]}, + ${"Build APIs and data workflows\n\n## Requirements\n\n- TypeScript\n- Postgres"}, ${["Tell us about ownership"]}, ${"open"}, ${5} diff --git a/app/features/interviews/shared/__tests__/runtime.test.ts b/app/features/interviews/shared/__tests__/runtime.test.ts index 68c4928c..8c7ae552 100644 --- a/app/features/interviews/shared/__tests__/runtime.test.ts +++ b/app/features/interviews/shared/__tests__/runtime.test.ts @@ -52,7 +52,6 @@ const seedInterviewContext = async () => { company_id, title, description, - requirements, screening_questions, status, final_report_target @@ -60,8 +59,7 @@ const seedInterviewContext = async () => { VALUES ( ${company.id}, ${"Backend Engineer"}, - ${"Build APIs and data pipelines"}, - ${["TypeScript", "Postgres"]}, + ${"Build APIs and data pipelines\n\n## Requirements\n\n- TypeScript\n- Postgres"}, ${["What is your notice period?"]}, ${"open"}, ${5} @@ -129,9 +127,37 @@ describe("interview runtime metadata", () => { expect(prompt).toContain("There is no fixed minimum, maximum, target, or turn count"); expect(prompt).toContain("A long resume does not require a long interview"); expect(prompt).toContain("highest-signal experiences and claims for this role"); + expect(prompt).not.toContain("- Requirements:\n(not provided)"); expect(prompt).not.toContain("end_interview"); }); + it("keeps legacy non-empty job requirements in the interview prompt", () => { + const prompt = buildInterviewSystemPrompt({ + runtimeContext: { + interviewId: crypto.randomUUID(), + applicationId: crypto.randomUUID(), + type: "full", + jobTitle: "Backend Engineer", + companyName: "Runtime Co", + jobDescription: "Build APIs.", + jobRequirements: ["Go"], + candidateName: "Jordan", + candidateSummary: "Built APIs.", + customQuestions: [], + preEvaluation: { + score: null, + missingRequirements: [], + consistencyScore: null, + authenticityFlags: [], + authenticityExplanation: null, + }, + }, + screeningCoverage: {}, + }); + + expect(prompt).toContain("- Requirements:\n- Go"); + }); + it("requires every company screening question before normal completion", () => { expect(areRequiredScreeningQuestionsResolved(0, {})).toBe(true); expect(areRequiredScreeningQuestionsResolved(2, { "1": "answered" })).toBe(false); @@ -192,7 +218,7 @@ describe("interview runtime metadata", () => { const metadata = await ensureInterviewRuntimeMetadata(sql, interview!); expect(metadata.jobSnapshot?.jobDescription).toContain("Build APIs"); expect(metadata.jobSnapshot?.customQuestions).toEqual(["What is your notice period?"]); - expect(metadata.jobSnapshot?.jobRequirements).toEqual(["TypeScript", "Postgres"]); + expect(metadata.jobSnapshot?.jobRequirements).toEqual([]); expect(Object.keys(metadata)).not.toContain("contextState"); expect(Object.keys(metadata)).not.toContain("preEvaluationScore"); diff --git a/app/features/interviews/shared/runtime.ts b/app/features/interviews/shared/runtime.ts index 867e7f3d..658983d6 100644 --- a/app/features/interviews/shared/runtime.ts +++ b/app/features/interviews/shared/runtime.ts @@ -199,7 +199,7 @@ export async function buildInterviewJobSnapshot( typeof job?.description === "string" ? sanitizeUntrustedText(job.description, LIMITS.UNTRUSTED_TEXT) : "", - jobRequirements: filterStrings(job?.requirements), + jobRequirements: [], customQuestions: filterStrings(job?.screeningQuestions), snapshottedAt: new Date().toISOString(), }; @@ -295,7 +295,7 @@ export function buildInterviewSystemPrompt(args: { const reqs = runtimeContext.jobRequirements.length > 0 ? runtimeContext.jobRequirements.map((requirement) => `- ${requirement}`).join("\n") - : "(not provided)"; + : null; const customQuestions = runtimeContext.customQuestions.length > 0 ? runtimeContext.customQuestions @@ -394,8 +394,7 @@ export function buildInterviewSystemPrompt(args: { `- Title: ${runtimeContext.jobTitle}`, `- Company: ${runtimeContext.companyName}`, `- Description: ${runtimeContext.jobDescription || "(not provided)"}`, - "- Requirements:", - reqs, + ...(reqs ? ["- Requirements:", reqs] : []), "", "Candidate:", `- Name: ${candidateName}`, diff --git a/app/features/job-imports/__tests__/adapters.test.ts b/app/features/job-imports/__tests__/adapters.test.ts index 694c39a7..3e56c3ec 100644 --- a/app/features/job-imports/__tests__/adapters.test.ts +++ b/app/features/job-imports/__tests__/adapters.test.ts @@ -47,8 +47,9 @@ describe("parseJobImportSource", () => { employmentType: "full_time", salaryMin: 100000, salaryMax: 150000, - requirements: ["TypeScript", "5 years experience"], }); + expect(candidate.job.description).toContain("TypeScript"); + expect(candidate.job.description).toContain("5 years experience"); }); it("normalizes a single Lever response", () => { @@ -133,7 +134,6 @@ describe("parseJobImportSource", () => { externalId: "smart-1", title: "Product Engineer", description: "Build reliable products.\n\nTypeScript", - requirements: ["TypeScript"], }); }); diff --git a/app/features/job-imports/__tests__/csv.test.ts b/app/features/job-imports/__tests__/csv.test.ts index 960dd50e..3fdbb915 100644 --- a/app/features/job-imports/__tests__/csv.test.ts +++ b/app/features/job-imports/__tests__/csv.test.ts @@ -11,8 +11,8 @@ describe("parseJobImportCsv", () => { workplaceType: "hybrid", employmentType: "full_time", experienceLevel: "senior", - requirements: ["TypeScript", "System design"], }); + expect(candidate.job.description).toContain("## About the role"); }); it("handles commas and line breaks inside quoted fields", async () => { diff --git a/app/features/job-imports/__tests__/normalization.test.ts b/app/features/job-imports/__tests__/normalization.test.ts index 8579fd7c..f23fbe14 100644 --- a/app/features/job-imports/__tests__/normalization.test.ts +++ b/app/features/job-imports/__tests__/normalization.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { htmlToPlainText } from "../server/normalization"; +import { MAX_JOB_DESCRIPTION_LENGTH } from "@/features/jobs/constants"; + +import type { JobImportWarning } from "../schemas"; +import { htmlToMarkdown, htmlToPlainText, normalizeDescription } from "../server/normalization"; describe("job import description normalization", () => { it("preserves readable structure while removing HTML", () => { @@ -10,8 +13,8 @@ describe("job import description normalization", () => { "
  • Own the roadmap
  • Support customers
", ].join(""); - expect(htmlToPlainText(html)).toBe( - "Who we are\n\nBuild the future with us.\n\n- Own the roadmap\n- Support customers", + expect(htmlToMarkdown(html)).toBe( + "## Who we are\n\nBuild the future with us.\n\n- Own the roadmap\n- Support customers", ); }); @@ -32,9 +35,62 @@ describe("job import description normalization", () => { ).toBe("Visible"); }); - it("is idempotent and preserves plain-text comparisons", () => { + it("is idempotent and preserves Markdown input", () => { const text = "Use a value < 5 and a result > 2.\n\n- Explain why."; - expect(htmlToPlainText(htmlToPlainText(text))).toBe(text); + expect(htmlToMarkdown(htmlToMarkdown(text))).toBe(text); + expect(htmlToPlainText(text)).toBe("Use a value < 5 and a result > 2.\n\nExplain why."); + }); + + it("preserves TypeScript generic syntax in plain Markdown", () => { + const text = "Use Array, Map, and Promise."; + + expect(htmlToMarkdown(text)).toBe(text); + }); + + it("creates a new requirements section after later description sections", () => { + const description = [ + "## Requirements", + "", + "- TypeScript", + "", + "## Benefits", + "", + "- Remote work", + ].join("\n"); + + expect(normalizeDescription(description, [], ["TypeScript", "PostgreSQL"])).toBe( + `${description}\n\n## Requirements\n\n- PostgreSQL`, + ); + }); + + it("does not treat a requirement as represented by a substring of another line", () => { + expect(normalizeDescription("We build services with JavaScript.", [], ["Java"])).toBe( + "We build services with JavaScript.\n\n## Requirements\n\n- Java", + ); + }); + + it("reserves space for imported requirements when shortening a description", () => { + const warnings: JobImportWarning[] = []; + const description = `## About the role\n\n${"word ".repeat(MAX_JOB_DESCRIPTION_LENGTH)}`; + + const normalized = normalizeDescription(description, warnings, ["TypeScript", "PostgreSQL"]); + + expect(normalized.length).toBeLessThanOrEqual(MAX_JOB_DESCRIPTION_LENGTH); + expect(normalized.endsWith("## Requirements\n\n- TypeScript\n- PostgreSQL")).toBe(true); + expect(warnings).toContainEqual( + expect.objectContaining({ code: "description_shortened", field: "description" }), + ); + }); + + it("does not append requirements inside an overlong fenced code block", () => { + const description = `

Intro paragraph.

${"code ".repeat(
+      MAX_JOB_DESCRIPTION_LENGTH,
+    )}
`; + + const normalized = normalizeDescription(description, [], ["Java"]); + + expect(normalized).toBe("Intro paragraph.\n\n## Requirements\n\n- Java"); + expect(normalized).not.toContain("```"); }); }); diff --git a/app/features/job-imports/__tests__/readiness.test.ts b/app/features/job-imports/__tests__/readiness.test.ts index 21cde314..1f7d6e4f 100644 --- a/app/features/job-imports/__tests__/readiness.test.ts +++ b/app/features/job-imports/__tests__/readiness.test.ts @@ -25,7 +25,6 @@ const item = (overrides?: Partial): JobImportItemResponse sourceUpdatedAt: null, title: "Platform Engineer", description: "Build the platform.", - requirements: [], location: "Remote", workplaceType: "remote", employmentType: "full_time", diff --git a/app/features/job-imports/__tests__/service.test.ts b/app/features/job-imports/__tests__/service.test.ts index 7718520d..a8befc7c 100644 --- a/app/features/job-imports/__tests__/service.test.ts +++ b/app/features/job-imports/__tests__/service.test.ts @@ -25,8 +25,8 @@ const candidate: JobImportCandidate = { sourceUrl: "https://jobs.lever.co/acme/lever-job-123", sourceUpdatedAt: "2026-08-01T10:00:00.000Z", title: "Platform Engineer", - description: "Build and operate the platform.", - requirements: ["TypeScript", "Distributed systems"], + description: + "Build and operate the platform.\n\n## Requirements\n\n- TypeScript\n- Distributed systems", location: "Remote", workplaceType: "remote", employmentType: "full_time", @@ -161,7 +161,7 @@ describe("job import service", () => { ], }); - expect(preview.items[0].job.description).toBe("Who we are\n\nBuild the future with us."); + expect(preview.items[0].job.description).toBe("## Who we are\n\nBuild the future with us."); const result = await importSelectedJobDrafts({ db: sql, @@ -175,7 +175,7 @@ describe("job import service", () => { WHERE id = ${result.imported[0].jobId} `; - expect(job.description).toBe("Who we are\n\nBuild the future with us."); + expect(job.description).toBe("## Who we are\n\nBuild the future with us."); }); it("imports persisted classifications without client overrides", async () => { @@ -233,7 +233,6 @@ describe("job import service", () => { expectedRevision: preview.items[0].revision, job: { description: candidate.job.description, - requirements: candidate.job.requirements, location: candidate.job.location, workplaceType: candidate.job.workplaceType, employmentType: candidate.job.employmentType, diff --git a/app/features/job-imports/components/import-review-workspace.tsx b/app/features/job-imports/components/import-review-workspace.tsx index a89c549f..22f826fa 100644 --- a/app/features/job-imports/components/import-review-workspace.tsx +++ b/app/features/job-imports/components/import-review-workspace.tsx @@ -112,9 +112,7 @@ export function ImportReviewWorkspace({ initialPreview }: { initialPreview: JobI const counts = getJobImportReadinessCounts(preview.items, mappings); const filteredItems = filterJobImportItems({ items: preview.items, mappings, filter, search }); const selectedItems = importableItems.filter((item) => selectedIds.has(item.id)); - const suggestableItems = selectedItems.filter( - (item) => item.job.requirements.length === 0 || !mappings[item.id]?.experienceLevel, - ); + const suggestableItems = selectedItems.filter((item) => !mappings[item.id]?.experienceLevel); const inspectorItem = preview.items.find((item) => item.id === inspectorId) ?? null; const mutationError = (error: Error) => toast.error(error.message || "The import failed."); @@ -251,7 +249,6 @@ export function ImportReviewWorkspace({ initialPreview }: { initialPreview: JobI job: { title: item.job.title, description: item.job.description, - requirements: item.job.requirements, location: item.job.location, workplaceType: mapping.workplaceType, employmentType: mapping.employmentType, @@ -278,7 +275,6 @@ export function ImportReviewWorkspace({ initialPreview }: { initialPreview: JobI job: { title: item.job.title, description: item.job.description, - requirements: item.job.requirements, location: item.job.location, workplaceType: mapping.workplaceType, employmentType: mapping.employmentType, @@ -528,7 +524,7 @@ export function ImportReviewWorkspace({ initialPreview }: { initialPreview: JobI Reviewing {suggestableItems.length} incomplete jobs… - RoundZero is looking for optional requirements and seniority in each source posting. + RoundZero is looking for seniority signals in each source posting. ) : null} diff --git a/app/features/job-imports/components/job-import-inspector.tsx b/app/features/job-imports/components/job-import-inspector.tsx index b2a4d917..bd3c36cd 100644 --- a/app/features/job-imports/components/job-import-inspector.tsx +++ b/app/features/job-imports/components/job-import-inspector.tsx @@ -22,11 +22,11 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; -import { Textarea } from "@/components/ui/textarea"; import type { EditableJobImportPayload, JobImportItemResponse, } from "@/features/job-imports/schemas"; +import { JobDescriptionEditor } from "@/features/jobs/components/job-description-editor"; import { employmentTypeLabels, employmentTypeSchema, @@ -64,7 +64,6 @@ export function JobImportInspector({ ? { title: item.job.title, description: item.job.description, - requirements: item.job.requirements, location: item.job.location, workplaceType: item.job.workplaceType, employmentType: item.job.employmentType, @@ -78,7 +77,6 @@ export function JobImportInspector({ : null, ); const [dirty, setDirty] = useState(false); - const [requirements, setRequirements] = useState(() => item?.job.requirements.join("\n") ?? ""); const onOpenChange = (open: boolean) => { if (open || saveStatus === "saving") return; if (dirty && item && job) { @@ -102,19 +100,9 @@ export function JobImportInspector({ ) => update({ [field]: event.target.value || (field === "location" ? null : "") }); const onTitleChange = (event: React.ChangeEvent) => onTextChange("title", event); - const onDescriptionChange = (event: React.ChangeEvent) => - onTextChange("description", event); + const onDescriptionChange = (value: string) => update({ description: value }); const onLocationChange = (event: React.ChangeEvent) => onTextChange("location", event); - const onRequirementsChange = (event: React.ChangeEvent) => { - setRequirements(event.target.value); - update({ - requirements: event.target.value - .split("\n") - .map((value) => value.trim()) - .filter(Boolean), - }); - }; const onBlur = () => { if (dirty) onSave(item.id, job); }; @@ -177,30 +165,12 @@ export function JobImportInspector({ Description -