feat(web): marketing Home page and the product component library (ADR 0023)
Product UI components are built once at design size from the Figma file and reused by the marketing mockups and, later, the app. Text contrast tokens now meet WCAG AA in both themes, with a test. Pricing stays hidden until decided. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SdXCbrN5PtGExkpvMaMHx
This commit is contained in:
@@ -1,20 +1,24 @@
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'ghost';
|
||||
/** `light` is for use on the dark closing panel. */
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'light';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
trailingIcon?: IconName;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Figma "Button": primary is the one main action on a screen; secondary and danger are added when a screen needs them. Heights 28, 36, 44. */
|
||||
export function Button({ href, children, variant = 'primary', size = 'md', className }: Props) {
|
||||
/** Figma "Button": primary is the one main action on a screen. Heights 28, 36, 44. */
|
||||
export function Button({ href, children, variant = 'primary', size = 'md', trailingIcon, className }: Props) {
|
||||
const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
|
||||
return (
|
||||
<Link href={href} className={classes}>
|
||||
{children}
|
||||
{trailingIcon && <Icon name={trailingIcon} size={20} />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
type Props = { name: 'menu' | 'chevron-right'; size?: number; className?: string };
|
||||
|
||||
/**
|
||||
* Draws a single-colour Figma icon with a CSS mask so it takes the text colour
|
||||
* of the current theme. The SVG file is the exact export from Figma.
|
||||
*/
|
||||
export function Icon({ name, size = 16, className }: Props) {
|
||||
const style = {
|
||||
'--icon-src': `url(/marketing/icons/${name}.svg)`,
|
||||
width: size,
|
||||
height: size,
|
||||
} as CSSProperties;
|
||||
return <span aria-hidden="true" className={['icon', className].filter(Boolean).join(' ')} style={style} />;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react';
|
||||
import { BrandMark } from './BrandMark.tsx';
|
||||
import { Button } from './Button.tsx';
|
||||
import { NAV_LINKS, ROUTES } from './content.ts';
|
||||
import { Icon } from './Icon.tsx';
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
|
||||
export function Nav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -43,3 +43,70 @@ export const FOOTER_COLUMNS: readonly FooterColumn[] = [
|
||||
];
|
||||
|
||||
export const TAGLINE = 'Your website, handled.';
|
||||
|
||||
/**
|
||||
* Plan names, prices and allowances are placeholders until decided (PRD D-HL-8, D-HL-14).
|
||||
* PX-13: the marketing site shows them only once they are approved. Flip this when they are.
|
||||
*/
|
||||
export const PRICING_CONFIRMED = false;
|
||||
|
||||
export type Plan = {
|
||||
name: string;
|
||||
price: string;
|
||||
allowance: string;
|
||||
featured?: boolean;
|
||||
};
|
||||
|
||||
/** Draft numbers from the Figma design. Not shown while PRICING_CONFIRMED is false. */
|
||||
export const PLANS: readonly Plan[] = [
|
||||
{ name: 'Starter', price: '$19', allowance: '1 site · 50 changes a month' },
|
||||
{ name: 'Studio', price: '$49', allowance: '5 sites · unlimited changes', featured: true },
|
||||
{ name: 'Agency', price: '$149', allowance: '25 sites · team seats and client sharing' },
|
||||
];
|
||||
|
||||
export const PLAN_FEATURES = [
|
||||
'Unlimited changes with approval',
|
||||
'Safe copy before every publish',
|
||||
'Proof for every change',
|
||||
'Undo for 30 days',
|
||||
] as const;
|
||||
|
||||
export const EXAMPLES = [
|
||||
{ title: 'Update the holiday hours', meta: 'Page · 2 min' },
|
||||
{ title: 'Add a gluten-free menu', meta: 'Menu · 4 min' },
|
||||
{ title: 'Fix the contact form', meta: 'Form · 3 min' },
|
||||
{ title: 'Swap the homepage photo', meta: 'Media · 1 min' },
|
||||
{ title: 'Publish a new blog post', meta: 'Post · 6 min' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* FAQ. The first answer is from the design. The rest are drafted from the PRD (sections 1.5, 2.2, 11.8 and 16)
|
||||
* for the product owner to review; they are not in the design yet.
|
||||
*/
|
||||
export const FAQ: ReadonlyArray<{ question: string; answer: string; open?: boolean }> = [
|
||||
{
|
||||
question: 'Will Wursor change my live site without asking?',
|
||||
answer: 'No. Every change is made on a safe copy first. Nothing goes live until you review the preview and approve it.',
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
question: 'Do I need my own hosting?',
|
||||
answer:
|
||||
'No. You can start a new site on Wursor Cloud, connect a WordPress site you already have, or build here and publish to your own WordPress. You can move between them later.',
|
||||
},
|
||||
{
|
||||
question: 'Can I undo a change?',
|
||||
answer:
|
||||
'Yes. Every change has a way back, and the undo button stays in view after you publish. If a change cannot be fully undone, Wursor says so before you approve it.',
|
||||
},
|
||||
{
|
||||
question: 'Does it work with my theme and plugins?',
|
||||
answer:
|
||||
'Wursor works on a copy of your site, so anything unusual shows up in the preview and the checks, not on your live pages. It tells you what it could not check.',
|
||||
},
|
||||
{
|
||||
question: 'How is my site kept safe?',
|
||||
answer:
|
||||
'Changes are made on a copy, a backup is taken before anything is applied, and each approval is tied to the exact checks you saw. You can disconnect Wursor from your site at any time.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApprovalCard } from '../../product/ApprovalCard.tsx';
|
||||
import { Fit } from '../../ui/Fit.tsx';
|
||||
import { Icon } from '../../ui/Icon.tsx';
|
||||
import { Button } from '../Button.tsx';
|
||||
import { SectionHeader } from './SectionHeader.tsx';
|
||||
|
||||
const POINTS = [
|
||||
'Nine checks, written in plain words',
|
||||
'Looks right on a phone, a tablet and a laptop',
|
||||
'One click to approve and publish, one click to undo',
|
||||
] as const;
|
||||
|
||||
export function ApproveWithProof() {
|
||||
return (
|
||||
<section className="m-approve">
|
||||
<div className="m-approve-text">
|
||||
<SectionHeader
|
||||
align="left"
|
||||
eyebrow="Approve with proof"
|
||||
title="See it before it’s real."
|
||||
subtitle="Drag to compare before and after. Read what Wursor checked. Then approve, or ask for changes."
|
||||
mobileSubtitle="Drag to compare before and after, read what Wursor checked, then approve."
|
||||
/>
|
||||
<ul className="m-points">
|
||||
{POINTS.map((p) => (
|
||||
<li key={p} className="t-body-l">
|
||||
<Icon name="check-circle" size={20} />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button href="/trust" variant="secondary" size="lg" trailingIcon="arrow-right" className="m-hide-mobile">
|
||||
How approval works
|
||||
</Button>
|
||||
</div>
|
||||
<div className="m-approve-visual" aria-hidden="true" inert>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="m-backdrop m-approve-backdrop" src="/marketing/backdrop.svg" alt="" />
|
||||
<div className="m-approve-card">
|
||||
<Fit designWidth={480} designHeight={441}>
|
||||
<ApprovalCard />
|
||||
</Fit>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Button } from '../Button.tsx';
|
||||
import { ROUTES } from '../content.ts';
|
||||
|
||||
export function ClosingCta() {
|
||||
return (
|
||||
<section className="m-cta">
|
||||
<div className="m-cta-panel">
|
||||
<h2 className="t-display-m m-cta-title">Hand off the busywork. Keep the final say.</h2>
|
||||
<p className="t-body-l m-cta-sub">Start a site or connect yours, and make your first change in minutes.</p>
|
||||
<Button href={ROUTES.signUp} variant="light" size="lg">
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SiteMock } from '../../product/SiteMock.tsx';
|
||||
import { Icon } from '../../ui/Icon.tsx';
|
||||
import { Zoom } from '../../ui/Zoom.tsx';
|
||||
import { EXAMPLES } from '../content.ts';
|
||||
import { SectionHeader } from './SectionHeader.tsx';
|
||||
|
||||
function Thumb({ state }: { state: 'before' | 'after' }) {
|
||||
return (
|
||||
<div className="m-thumb" data-state={state}>
|
||||
<Zoom ratio={130 / 760}>
|
||||
<SiteMock state={state} />
|
||||
</Zoom>
|
||||
<span className="m-thumb-label t-caption">{state === 'before' ? 'Before' : 'After'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Examples() {
|
||||
return (
|
||||
<section className="m-section">
|
||||
<SectionHeader
|
||||
eyebrow="Examples"
|
||||
title="Small jobs, done in minutes."
|
||||
subtitle="Real requests from real owners. Each one ends with a preview you approve."
|
||||
/>
|
||||
<ul aria-label="Examples" className="m-carousel">
|
||||
{EXAMPLES.map((e) => (
|
||||
<li key={e.title} className="m-example">
|
||||
<div className="m-thumbs" aria-hidden="true">
|
||||
<Thumb state="before" />
|
||||
<Thumb state="after" />
|
||||
</div>
|
||||
<p className="t-title-s">{e.title}</p>
|
||||
<p className="m-example-meta t-caption">
|
||||
<Icon name="clock" size={20} />
|
||||
{e.meta}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Icon } from '../../ui/Icon.tsx';
|
||||
import { FAQ } from '../content.ts';
|
||||
import { SectionHeader } from './SectionHeader.tsx';
|
||||
|
||||
export function Faq() {
|
||||
return (
|
||||
<section className="m-section">
|
||||
<SectionHeader eyebrow="FAQ" title="Questions, answered." />
|
||||
<div className="m-faq">
|
||||
{FAQ.map((item) => (
|
||||
<details key={item.question} className="m-faq-item" open={item.open}>
|
||||
<summary className="m-faq-question t-title-s">
|
||||
<span>{item.question}</span>
|
||||
<Icon name="chevron-down" size={20} className="m-faq-chevron" />
|
||||
</summary>
|
||||
<p className="t-body-m m-section-sub">{item.answer}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Workspace } from '../../product/Workspace.tsx';
|
||||
import { Fit } from '../../ui/Fit.tsx';
|
||||
import { Icon } from '../../ui/Icon.tsx';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '../Button.tsx';
|
||||
import { ROUTES, TAGLINE } from '../content.ts';
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="m-hero">
|
||||
<Link href="/trust" className="m-announce">
|
||||
<span className="m-announce-new t-label-s">New</span>
|
||||
<span className="t-label-m">Approve with proof: nine checks on every change</span>
|
||||
<Icon name="chevron-right" size={20} />
|
||||
</Link>
|
||||
<h1 className="t-display-xl m-hero-title">{TAGLINE}</h1>
|
||||
<p className="t-body-l m-hero-sub">Tell Wursor what to change. Watch it work. Approve what you like.</p>
|
||||
<div className="m-hero-actions">
|
||||
<Button href={ROUTES.signUp} size="lg">
|
||||
Get started
|
||||
</Button>
|
||||
<Button href="/how-it-works" variant="ghost" size="lg" trailingIcon="arrow-right">
|
||||
See how it works
|
||||
</Button>
|
||||
</div>
|
||||
<p className="t-caption m-hero-footnote">For WordPress sites · We host it, or you do</p>
|
||||
<div className="m-hero-visual">
|
||||
<div className="m-visual" aria-hidden="true" inert>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="m-backdrop" src="/marketing/backdrop.svg" alt="" />
|
||||
<div className="m-hero-shot">
|
||||
<Fit designWidth={1440} designHeight={900} initialScale={1100 / 1440} minScale={0.454}>
|
||||
<Workspace />
|
||||
</Fit>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Icon } from '../../ui/Icon.tsx';
|
||||
import { Button } from '../Button.tsx';
|
||||
import { PLAN_FEATURES, PLANS, PRICING_CONFIRMED, ROUTES } from '../content.ts';
|
||||
import { SectionHeader } from './SectionHeader.tsx';
|
||||
|
||||
/**
|
||||
* Plan names, prices and allowances show only once they are decided (PRD D-HL-8, PX-13). Until then
|
||||
* this section says so and sends people to sign up or to the pricing page.
|
||||
*/
|
||||
export function PricingTeaser({ confirmed = PRICING_CONFIRMED }: { confirmed?: boolean }) {
|
||||
if (!confirmed) {
|
||||
return (
|
||||
<section className="m-section">
|
||||
<SectionHeader eyebrow="Pricing" title="Simple plans. No surprises." subtitle="Plans and prices will be published before launch." />
|
||||
<div className="m-pricing-actions">
|
||||
<Button href={ROUTES.signUp} size="lg">
|
||||
Get started
|
||||
</Button>
|
||||
<Button href="/pricing" variant="ghost" size="lg" trailingIcon="arrow-right">
|
||||
See pricing
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="m-section">
|
||||
<SectionHeader
|
||||
eyebrow="Pricing"
|
||||
title="Simple plans. No surprises."
|
||||
subtitle="Hosting on Wursor Cloud is included. Change or cancel any time."
|
||||
/>
|
||||
<div className="m-plans">
|
||||
{PLANS.map((p) => (
|
||||
<div key={p.name} className={`m-plan${p.featured ? ' m-plan-featured' : ''}`}>
|
||||
<div className="m-plan-head">
|
||||
<h3 className="t-title-s">{p.name}</h3>
|
||||
{p.featured && <span className="m-plan-tag t-label-m">Most popular</span>}
|
||||
</div>
|
||||
<p className="m-plan-price">
|
||||
<span className="t-title-l">{p.price}</span>
|
||||
<span className="t-caption">per month</span>
|
||||
</p>
|
||||
<p className="t-body-m m-section-sub">{p.allowance}</p>
|
||||
<ul className="m-plan-features">
|
||||
{PLAN_FEATURES.map((f) => (
|
||||
<li key={f} className="t-body-m">
|
||||
<Icon name="check" size={20} />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button href={ROUTES.signUp} variant={p.featured ? 'primary' : 'secondary'} className="m-plan-cta">
|
||||
Choose plan
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const STATS = [
|
||||
{ value: '9', label: 'checks before anything goes live' },
|
||||
{ value: '1 click', label: 'to undo any change, any time' },
|
||||
{ value: 'Every step', label: 'on the record, in plain words' },
|
||||
] as const;
|
||||
|
||||
export function ProofStrip() {
|
||||
return (
|
||||
<section aria-label="Proof" className="m-proof">
|
||||
{STATS.map((s) => (
|
||||
<div key={s.value} className="m-proof-stat">
|
||||
<p className="t-display-l">{s.value}</p>
|
||||
<p className="t-body-l m-section-sub">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Icon, type IconName } from '../../ui/Icon.tsx';
|
||||
import { SectionHeader } from './SectionHeader.tsx';
|
||||
|
||||
const RULES: ReadonlyArray<{ icon: IconName; title: string; body: string }> = [
|
||||
{
|
||||
icon: 'shield',
|
||||
title: 'Works on a safe copy',
|
||||
body: 'Every change is made on a copy of your site first, so your live pages stay untouched until you say so.',
|
||||
},
|
||||
{
|
||||
icon: 'check-circle',
|
||||
title: 'Nothing goes live without you',
|
||||
body: 'Wursor shows you the result and waits. Nothing is published without your approval.',
|
||||
},
|
||||
{
|
||||
icon: 'list',
|
||||
title: 'Everything is recorded',
|
||||
body: 'Every step, check and decision is written down, so you can see exactly what happened and why.',
|
||||
},
|
||||
{
|
||||
icon: 'undo',
|
||||
title: 'Undo in one click',
|
||||
body: 'If you change your mind, one click puts your site back the way it was.',
|
||||
},
|
||||
];
|
||||
|
||||
export function SafeByDesign() {
|
||||
return (
|
||||
<section className="m-section">
|
||||
<SectionHeader
|
||||
eyebrow="Safe by design"
|
||||
title="Nothing goes live until you say so."
|
||||
subtitle="Four rules Wursor can’t break, however you set it up."
|
||||
mobileSubtitle="Four rules Wursor can’t break."
|
||||
/>
|
||||
<div className="m-rules">
|
||||
{RULES.map((r) => (
|
||||
<div key={r.title} className="m-rule">
|
||||
<span className="m-rule-icon">
|
||||
<Icon name={r.icon} size={20} />
|
||||
</span>
|
||||
<h3 className="t-title-s">{r.title}</h3>
|
||||
<p className="t-body-m m-section-sub">{r.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Shows `desktop` on wide screens and `mobile` on narrow ones. The design words a few lines more briefly on a phone. */
|
||||
export function Responsive({ desktop, mobile }: { desktop: ReactNode; mobile?: ReactNode }) {
|
||||
if (mobile === undefined) return <>{desktop}</>;
|
||||
return (
|
||||
<>
|
||||
<span className="m-d">{desktop}</span>
|
||||
<span className="m-m">{mobile}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
mobileTitle?: string;
|
||||
mobileSubtitle?: string;
|
||||
align?: 'center' | 'left';
|
||||
};
|
||||
|
||||
/** Eyebrow, headline and one supporting sentence. Centred by default. */
|
||||
export function SectionHeader({ eyebrow, title, subtitle, mobileTitle, mobileSubtitle, align = 'center' }: Props) {
|
||||
return (
|
||||
<div className={`m-section-header m-align-${align}`}>
|
||||
<p className="t-overline m-eyebrow">{eyebrow}</p>
|
||||
<h2 className="t-display-l m-section-title">
|
||||
<Responsive desktop={title} mobile={mobileTitle} />
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="t-body-l m-section-sub">
|
||||
<Responsive desktop={subtitle} mobile={mobileSubtitle} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
|
||||
import { BoardStrip } from '../../product/BoardStrip.tsx';
|
||||
import { HARBOR } from '../../product/harbor-bakery.ts';
|
||||
import { HarborConversation } from '../../product/HarborConversation.tsx';
|
||||
import { PreviewColumn } from '../../product/PreviewColumn.tsx';
|
||||
import { Fit } from '../../ui/Fit.tsx';
|
||||
import { Icon, type IconName } from '../../ui/Icon.tsx';
|
||||
import { Responsive } from './SectionHeader.tsx';
|
||||
|
||||
type Surface = 'conversation' | 'preview' | 'board';
|
||||
|
||||
const SURFACES: ReadonlyArray<{ value: Surface; label: string; icon: IconName; caption: string; mobileCaption?: string }> = [
|
||||
{ value: 'conversation', label: 'Conversation', icon: 'sparkles', caption: 'Conversation: ask in plain words. Wursor explains each step as it goes.', mobileCaption: 'Ask in plain words. Wursor explains each step.' },
|
||||
{ value: 'preview', label: 'Preview', icon: 'eye', caption: 'Preview: see the real page on a phone, tablet and laptop before anything goes live.' },
|
||||
{ value: 'board', label: 'Board', icon: 'board', caption: 'Board: every change in one place, from idea to published.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Each window is a share of the stage width with the design's aspect ratio, and its content is scaled to the
|
||||
* window's own width, so the three views stay in proportion on any screen. Sizes are from the design (1344 × 720 stage).
|
||||
*/
|
||||
const WINDOWS: Record<Surface, { width: string; aspect: string; contentWidth: number; contentHeight: number; initialScale: number }> = {
|
||||
conversation: { width: '41.667%', aspect: '560 / 620', contentWidth: 440, contentHeight: 852, initialScale: 560 / 440 },
|
||||
preview: { width: '60.863%', aspect: '818 / 600', contentWidth: 760, contentHeight: 852, initialScale: 818 / 760 },
|
||||
board: { width: '78.571%', aspect: '1056 / 341', contentWidth: 760, contentHeight: 246, initialScale: 1056 / 760 },
|
||||
};
|
||||
|
||||
function SurfaceWindow({ surface }: { surface: Surface }) {
|
||||
const w = WINDOWS[surface];
|
||||
const style = { '--w': w.width, aspectRatio: w.aspect } as CSSProperties;
|
||||
return (
|
||||
<div className="m-window" style={style}>
|
||||
<Fit designWidth={w.contentWidth} designHeight={w.contentHeight} initialScale={w.initialScale} maxScale={3}>
|
||||
{surface === 'conversation' && <HarborConversation />}
|
||||
{surface === 'preview' && <PreviewColumn />}
|
||||
{surface === 'board' && <BoardStrip lanes={HARBOR.board} />}
|
||||
</Fit>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SurfacesShowcase() {
|
||||
const [selected, setSelected] = useState<Surface>('conversation');
|
||||
const id = useId();
|
||||
const tabs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const current = SURFACES.find((s) => s.value === selected)!;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
const next = (index + step + SURFACES.length) % SURFACES.length;
|
||||
setSelected(SURFACES[next]!.value);
|
||||
tabs.current[next]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="m-showcase">
|
||||
<div role="tablist" aria-label="Workspace views" className="m-feature-tabs">
|
||||
{SURFACES.map((s, i) => (
|
||||
<button
|
||||
key={s.value}
|
||||
ref={(el) => {
|
||||
tabs.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${id}-tab-${s.value}`}
|
||||
aria-selected={s.value === selected}
|
||||
aria-controls={`${id}-panel`}
|
||||
tabIndex={s.value === selected ? 0 : -1}
|
||||
className="m-feature-tab t-label-m"
|
||||
onClick={() => setSelected(s.value)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
>
|
||||
<Icon name={s.icon} size={20} />
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-tab-${selected}`} className="m-showcase-panel">
|
||||
<div className="m-surface" aria-hidden="true" inert>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="m-backdrop" src="/marketing/backdrop.svg" alt="" />
|
||||
<SurfaceWindow surface={selected} />
|
||||
</div>
|
||||
<p className="t-body-l m-section-sub m-caption">
|
||||
<Responsive desktop={current.caption} mobile={current.mobileCaption} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Density } from '../../product/ConversationPanel.tsx';
|
||||
import { Workspace } from '../../product/Workspace.tsx';
|
||||
import { Fit } from '../../ui/Fit.tsx';
|
||||
import { SectionHeader } from './SectionHeader.tsx';
|
||||
|
||||
const LEVELS: ReadonlyArray<{ density: Density; title: string; description: string }> = [
|
||||
{ density: 'headline', title: 'Headline', description: 'Only what changed and what needs you.' },
|
||||
{ density: 'steps', title: 'Steps', description: 'Each step, in a sentence. The default.' },
|
||||
{ density: 'everything', title: 'Everything', description: 'Every look, edit, check and decision.' },
|
||||
];
|
||||
|
||||
export function WatchTheWork() {
|
||||
return (
|
||||
<section className="m-section">
|
||||
<SectionHeader
|
||||
eyebrow="The play-by-play"
|
||||
title="Watch the work, your way."
|
||||
subtitle="Wursor narrates every step. Choose how much you see: just the headlines, the steps, or everything it does."
|
||||
mobileTitle="Watch every step."
|
||||
mobileSubtitle="Choose how much you see: the headlines, the steps, or everything."
|
||||
/>
|
||||
<div className="m-densities">
|
||||
{LEVELS.map((l) => (
|
||||
<div key={l.density} className="m-density">
|
||||
<div className="m-density-head">
|
||||
<h3 className="t-title-m">{l.title}</h3>
|
||||
<p className="t-body-m m-section-sub">{l.description}</p>
|
||||
</div>
|
||||
<div className="m-crop" aria-hidden="true" inert>
|
||||
<Fit designWidth={440} designHeight={600}>
|
||||
<div className="m-crop-frame">
|
||||
<div className="m-crop-shot">
|
||||
<Workspace defaultDensity={l.density} />
|
||||
</div>
|
||||
</div>
|
||||
</Fit>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
|
||||
/** Wursor's pointer on the preview, with its name. Position is in preview-stage pixels. */
|
||||
export function AgentCursor({ left, top, name = 'Wursor' }: { left: number; top: number; name?: string }) {
|
||||
const style: CSSProperties = { left, top };
|
||||
return (
|
||||
<div className="p-cursor" style={style} aria-hidden="true">
|
||||
<Icon name="pointer" size={20} />
|
||||
<span className="p-cursor-name">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
import { Button } from './Button.tsx';
|
||||
|
||||
type Props = { title: string; meta: string; onApprove?: () => void };
|
||||
|
||||
/** The decision point. Ready state only: approve, or (later) ask for changes. Publishing and Published are not designed here yet. */
|
||||
export function ApprovalBar({ title, meta, onApprove }: Props) {
|
||||
return (
|
||||
<div className="p-approval">
|
||||
<span className="p-approval-icon">
|
||||
<Icon name="check-circle" size={20} />
|
||||
</span>
|
||||
<div className="p-approval-text">
|
||||
<p className="p-approval-title">{title}</p>
|
||||
<p className="p-approval-meta">{meta}</p>
|
||||
</div>
|
||||
<Button variant="primary" size="md" trailingIcon="arrow-right" onClick={onApprove}>
|
||||
Approve and publish
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApprovalBar } from './ApprovalBar.tsx';
|
||||
import { CheckEvent } from './CheckEvent.tsx';
|
||||
import { CompareSlider } from './CompareSlider.tsx';
|
||||
import { Zoom } from '../ui/Zoom.tsx';
|
||||
import { SiteMock } from './SiteMock.tsx';
|
||||
|
||||
/** Compare slider, the checks and the approval bar together: what the owner sees before anything goes live. */
|
||||
export function ApprovalCard() {
|
||||
return (
|
||||
<div className="p-approvalcard">
|
||||
<Zoom ratio={448 / 760}>
|
||||
<div className="p-approvalcard-compare">
|
||||
<CompareSlider before={<SiteMock state="before" />} after={<SiteMock state="after" />} />
|
||||
</div>
|
||||
</Zoom>
|
||||
<CheckEvent result="passed" title="Checked 9 things. All passed." detail="Phone link works, 3 screen sizes, no new errors, speed unchanged." />
|
||||
<ApprovalBar title="Ready for you" meta="2 changes · Everyday risk" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
type Props =
|
||||
| { kind: 'wursor'; size?: 'sm' | 'md' }
|
||||
| { kind: 'initials'; initials: string; name: string; size?: 'sm' | 'md' };
|
||||
|
||||
/** Person or Wursor agent identity: initials, or the brand mark on the inverse colour. */
|
||||
export function Avatar(props: Props) {
|
||||
const size = props.size ?? 'sm';
|
||||
if (props.kind === 'wursor') {
|
||||
return (
|
||||
<span role="img" aria-label="Wursor" className={`p-avatar p-avatar-${size} p-avatar-wursor`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="p-avatar-mark p-avatar-mark-light" src="/marketing/avatar-mark.svg" alt="" width={14} height={14} />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="p-avatar-mark p-avatar-mark-dark" src="/marketing/avatar-mark-dark.svg" alt="" width={14} height={14} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span role="img" aria-label={props.name} className={`p-avatar p-avatar-${size} p-avatar-initials`}>
|
||||
{props.initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RiskChip } from './RiskChip.tsx';
|
||||
import { StatusPill, type Status } from './StatusPill.tsx';
|
||||
|
||||
type Props = {
|
||||
status: Status;
|
||||
title: string;
|
||||
meta: string;
|
||||
risk?: 'everyday';
|
||||
/** 0 to 1. Shown on a working change. */
|
||||
progress?: number;
|
||||
};
|
||||
|
||||
/** One change on the board. A change that needs the owner is outlined in the accent colour and says why. */
|
||||
export function BoardCard({ status, title, meta, risk, progress }: Props) {
|
||||
return (
|
||||
<div className="p-boardcard" data-status={status}>
|
||||
<p className="p-boardcard-title">{title}</p>
|
||||
<p className="p-boardcard-meta">{meta}</p>
|
||||
<div className="p-boardcard-footer">
|
||||
<StatusPill status={status} />
|
||||
{status === 'working' && risk && <RiskChip level={risk} />}
|
||||
</div>
|
||||
{status === 'working' && progress !== undefined && (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={title}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(progress * 100)}
|
||||
className="p-progress"
|
||||
>
|
||||
<span className="p-progress-fill" style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{status === 'needs-you' && <p className="p-boardcard-ask">Wursor is waiting for your answer</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BoardCard } from './BoardCard.tsx';
|
||||
import { Counter } from './Counter.tsx';
|
||||
import type { BoardLaneData } from './harbor-bakery.ts';
|
||||
|
||||
/** The board as a strip under the preview: four lanes, each with its count. The header count is what is not done yet. */
|
||||
export function BoardStrip({ lanes }: { lanes: readonly BoardLaneData[] }) {
|
||||
const open = lanes.filter((l) => l.status !== 'done').reduce((n, l) => n + l.cards.length, 0);
|
||||
return (
|
||||
<section aria-label="Board" className="p-board">
|
||||
<div className="p-board-header">
|
||||
<h3 className="p-board-title">Board</h3>
|
||||
<Counter count={open} />
|
||||
<span className="p-spacer" />
|
||||
<span className="p-board-link">Open board</span>
|
||||
</div>
|
||||
<div className="p-lanes">
|
||||
{lanes.map((lane) => (
|
||||
<div key={lane.status} role="group" aria-label={lane.title} className="p-lane">
|
||||
<div className="p-lane-header">
|
||||
<span>{lane.title}</span>
|
||||
<Counter count={lane.cards.length} />
|
||||
</div>
|
||||
{lane.cards.map((c) => (
|
||||
<BoardCard key={c.title} {...c} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md';
|
||||
leadingIcon?: IconName;
|
||||
trailingIcon?: IconName;
|
||||
onClick?: (() => void) | undefined;
|
||||
};
|
||||
|
||||
/** Product button. Primary (black pill) is the one main action on a screen; secondary is the alternative; ghost is tertiary. */
|
||||
export function Button({ children, variant = 'secondary', size = 'sm', leadingIcon, trailingIcon, onClick }: Props) {
|
||||
const iconSize = 16;
|
||||
return (
|
||||
<button type="button" className={`p-btn p-btn-${variant} p-btn-${size}`} onClick={onClick}>
|
||||
{leadingIcon && <Icon name={leadingIcon} size={iconSize} />}
|
||||
<span>{children}</span>
|
||||
{trailingIcon && <Icon name={trailingIcon} size={iconSize} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
import { Button } from './Button.tsx';
|
||||
import { RiskChip } from './RiskChip.tsx';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
meta: string;
|
||||
risk: 'everyday';
|
||||
added: number;
|
||||
removed: number;
|
||||
onSeeChanges?: () => void;
|
||||
};
|
||||
|
||||
/** A change Wursor made to the draft copy. Always shows the risk, how much changed, and a route to the full comparison. */
|
||||
export function ChangeEvent({ title, meta, risk, added, removed, onSeeChanges }: Props) {
|
||||
return (
|
||||
<div className="p-change">
|
||||
<div className="p-change-top">
|
||||
<span className="p-change-tile">
|
||||
<Icon name="pencil" size={20} />
|
||||
</span>
|
||||
<div className="p-change-text">
|
||||
<p className="p-change-title">{title}</p>
|
||||
<p className="p-change-meta">{meta}</p>
|
||||
</div>
|
||||
<RiskChip level={risk} />
|
||||
</div>
|
||||
<div className="p-change-diff">
|
||||
<span className="p-diff-add">+{added} lines</span>
|
||||
<span className="p-diff-remove">−{removed} lines</span>
|
||||
<span className="p-spacer" />
|
||||
<Button leadingIcon="split" onClick={onSeeChanges}>
|
||||
See what changed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
|
||||
type Props = { result: 'passed'; title: string; detail: string };
|
||||
|
||||
/** Wursor's own checks after a change. Passed is quiet and green. "Needs attention" is not designed yet. */
|
||||
export function CheckEvent({ result, title, detail }: Props) {
|
||||
return (
|
||||
<div className="p-check" data-result={result}>
|
||||
<span className="p-check-icon">
|
||||
<Icon name="check-circle" size={20} />
|
||||
</span>
|
||||
<div className="p-check-text">
|
||||
<p className="p-check-title">{title}</p>
|
||||
<p className="p-check-detail">{detail}</p>
|
||||
</div>
|
||||
<Icon name="chevron-right" size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
|
||||
type Props = { before: ReactNode; after: ReactNode; defaultPosition?: number };
|
||||
|
||||
const STEP = 5;
|
||||
const clamp = (n: number) => Math.min(100, Math.max(0, n));
|
||||
|
||||
/** Drag the divider (or use the arrow keys) to compare the live site on the left with the draft on the right. */
|
||||
export function CompareSlider({ before, after, defaultPosition = 50 }: Props) {
|
||||
const [position, setPosition] = useState(clamp(defaultPosition));
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
|
||||
const moveTo = (clientX: number) => {
|
||||
const rect = box.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width === 0) return;
|
||||
setPosition(clamp(((clientX - rect.left) / rect.width) * 100));
|
||||
};
|
||||
const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
moveTo(e.clientX);
|
||||
};
|
||||
const onPointerMove = (e: PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.buttons === 1) moveTo(e.clientX);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const next: Record<string, number | undefined> = {
|
||||
ArrowLeft: position - STEP,
|
||||
ArrowRight: position + STEP,
|
||||
Home: 0,
|
||||
End: 100,
|
||||
};
|
||||
const value = next[e.key];
|
||||
if (value === undefined) return;
|
||||
e.preventDefault();
|
||||
setPosition(clamp(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={box} className="p-compare">
|
||||
<div className="p-compare-layer">{after}</div>
|
||||
<div className="p-compare-layer p-compare-before" style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}>
|
||||
{before}
|
||||
</div>
|
||||
<span className="p-compare-divider" style={{ left: `${position}%` }} aria-hidden="true" />
|
||||
<span className="p-compare-label p-compare-label-before">Before</span>
|
||||
<span className="p-compare-label p-compare-label-after">After</span>
|
||||
<button
|
||||
type="button"
|
||||
role="slider"
|
||||
aria-label="Compare before and after"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(position)}
|
||||
aria-valuetext={`${Math.round(position)}% before`}
|
||||
className="p-compare-handle"
|
||||
style={{ left: `${position}%` }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<Icon name="split" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button } from './Button.tsx';
|
||||
import { IconButton } from './IconButton.tsx';
|
||||
|
||||
type Props = {
|
||||
/** Only the working state is designed so far. Empty, typing and pointing come with the app screens. */
|
||||
state: 'working';
|
||||
autonomy: string;
|
||||
placeholder?: string;
|
||||
onStop?: () => void;
|
||||
};
|
||||
|
||||
/** Where the owner talks to Wursor. While Wursor works, stop is offered and typing still works. */
|
||||
export function Composer({ autonomy, placeholder = 'Wursor is working. Add a note, or stop.', onStop }: Props) {
|
||||
return (
|
||||
<div className="p-composer" data-state="working">
|
||||
<textarea aria-label="Message to Wursor" placeholder={placeholder} rows={1} className="p-composer-input" />
|
||||
<div className="p-composer-toolbar">
|
||||
<div className="p-composer-side">
|
||||
<IconButton icon="paperclip" label="Attach a file" />
|
||||
<IconButton icon="pointer" label="Point at something on the page" />
|
||||
<Button variant="ghost" trailingIcon="chevron-down">
|
||||
{autonomy}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-composer-side">
|
||||
<IconButton icon="mic" label="Dictate" />
|
||||
<IconButton icon="stop" label="Stop" variant="inverse" onClick={onStop} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Composer } from './Composer.tsx';
|
||||
import { SegmentedControl } from './SegmentedControl.tsx';
|
||||
|
||||
export type Density = 'headline' | 'steps' | 'everything';
|
||||
|
||||
const DENSITIES = [
|
||||
{ value: 'headline', label: 'Headline' },
|
||||
{ value: 'steps', label: 'Steps' },
|
||||
{ value: 'everything', label: 'Everything' },
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
density: Density;
|
||||
onDensityChange: (density: Density) => void;
|
||||
composer?: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/** The conversation: header with the detail level, the stream, and the composer. */
|
||||
export function ConversationPanel({ density, onDensityChange, composer, children }: Props) {
|
||||
return (
|
||||
<section aria-label="Conversation panel" className="p-convo">
|
||||
<div className="p-convo-header">
|
||||
<h2 className="p-convo-title">Conversation</h2>
|
||||
<span className="p-spacer" />
|
||||
<SegmentedControl label="Detail" options={DENSITIES} value={density} onChange={onDensityChange} />
|
||||
</div>
|
||||
<div className="p-stream" data-density={density}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="p-composer-area">{composer ?? <Composer state="working" autonomy="Ask first" />}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Small count badge on the accent colour. */
|
||||
export function Counter({ count }: { count: number }) {
|
||||
return <span className="p-counter">{count}</span>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
|
||||
/** A browser window around a page. Drawn at 760 px wide and shown at 712 px, the width of the preview stage. */
|
||||
export function DeviceFrame({ url, children }: { url: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="p-device">
|
||||
<div className="p-device-scale">
|
||||
<div className="p-device-bar">
|
||||
<Icon name="dots" size={40} className="p-device-dots" />
|
||||
<div className="p-device-address">
|
||||
<Icon name="lock" size={20} />
|
||||
<span>{url}</span>
|
||||
</div>
|
||||
<Icon name="refresh" size={20} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ConversationPanel, type Density } from './ConversationPanel.tsx';
|
||||
import { HarborStream } from './HarborStream.tsx';
|
||||
|
||||
/** The Harbor Bakery conversation on its own, with its own detail level. */
|
||||
export function HarborConversation({ defaultDensity = 'steps' }: { defaultDensity?: Density }) {
|
||||
const [density, setDensity] = useState<Density>(defaultDensity);
|
||||
return (
|
||||
<ConversationPanel density={density} onDensityChange={setDensity}>
|
||||
<HarborStream density={density} />
|
||||
</ConversationPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ChangeEvent } from './ChangeEvent.tsx';
|
||||
import type { Density } from './ConversationPanel.tsx';
|
||||
import { Message } from './Message.tsx';
|
||||
import { Milestone } from './Milestone.tsx';
|
||||
import { StepGroup } from './StepGroup.tsx';
|
||||
import { StepLine } from './StepLine.tsx';
|
||||
import { Thought } from './Thought.tsx';
|
||||
|
||||
const REASONING =
|
||||
'The number is plain text on the Contact page and again in the footer. On a phone it needs to be a tel: link, and the tap target should be at least 44 px tall. I’ll check the header too, because it repeats the number.';
|
||||
|
||||
const CHANGE = { title: 'Changed the Contact page', meta: '1 section · 2 places', risk: 'everyday', added: 8, removed: 2 } as const;
|
||||
|
||||
/** The example conversation for a Harbor Bakery change, at each detail level. */
|
||||
export function HarborStream({ density }: { density: Density }) {
|
||||
if (density === 'headline') {
|
||||
return (
|
||||
<>
|
||||
<Message role="user">Make the phone number tappable on mobile, and the same in the footer.</Message>
|
||||
<Message role="wursor">
|
||||
Two places show your phone number as plain text: the Contact page and the footer. Here is my plan. Nothing goes live until you approve.
|
||||
</Message>
|
||||
<Milestone>Plan ready</Milestone>
|
||||
<Message role="wursor">I’ve changed the Contact page and the footer, and I’m checking them on a phone now.</Message>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (density === 'steps') {
|
||||
return (
|
||||
<>
|
||||
<Message role="user">Make the phone number tappable on mobile, and the same in the footer.</Message>
|
||||
<Message role="wursor">On it. I’ll show you each step as I go.</Message>
|
||||
<StepGroup title="Looked at 4 pages" count={4}>
|
||||
<StepLine kind="look" time="1s">Looked at the home page</StepLine>
|
||||
</StepGroup>
|
||||
<Thought seconds={6}>{REASONING}</Thought>
|
||||
<StepLine kind="edit" time="4s">Made the Contact page number a tap-to-call link</StepLine>
|
||||
<ChangeEvent {...CHANGE} />
|
||||
<StepLine kind="edit" time="3s">Did the same in the footer</StepLine>
|
||||
<StepLine kind="running" time="now">Taking a photo of the page on a phone</StepLine>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Message role="wursor">On it. I’ll show you each step as I go.</Message>
|
||||
<Thought seconds={6} defaultExpanded>{REASONING}</Thought>
|
||||
<StepLine kind="look" time="1s">Looked at home page</StepLine>
|
||||
<StepLine kind="look" time="1s">Looked at contact page</StepLine>
|
||||
<StepLine kind="look" time="1s">Looked at footer</StepLine>
|
||||
<StepLine kind="search" time="2s">Searched your pages for the phone number</StepLine>
|
||||
<StepLine kind="edit" time="4s">Made the Contact page number a tap-to-call link</StepLine>
|
||||
<ChangeEvent {...CHANGE} />
|
||||
<StepLine kind="edit" time="3s">Did the same in the footer</StepLine>
|
||||
<StepLine kind="running" time="now">Taking a photo of the page on a phone</StepLine>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||
|
||||
type Props = {
|
||||
icon: IconName;
|
||||
label: string;
|
||||
variant?: 'ghost' | 'inverse';
|
||||
size?: 'sm' | 'md';
|
||||
onClick?: (() => void) | undefined;
|
||||
};
|
||||
|
||||
/** Round button with an icon, for toolbars and the composer. `label` is its accessible name. */
|
||||
export function IconButton({ icon, label, variant = 'ghost', size = 'sm', onClick }: Props) {
|
||||
return (
|
||||
<button type="button" aria-label={label} className={`p-iconbtn p-iconbtn-${variant} p-iconbtn-${size}`} onClick={onClick}>
|
||||
<Icon name={icon} size={size === 'sm' ? 16 : 20} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Avatar } from './Avatar.tsx';
|
||||
|
||||
/** A message in the conversation. The owner's sit in a soft box; Wursor's are plain text beside its avatar. */
|
||||
export function Message({ role, children }: { role: 'user' | 'wursor'; children: ReactNode }) {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="p-message p-message-user">
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="p-message p-message-wursor">
|
||||
<Avatar kind="wursor" />
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
|
||||
/** A divider in the stream that marks a stage in the life of a change. Always visible, even at the Headline level. */
|
||||
export function Milestone({ children }: { children: string | ReactNode }) {
|
||||
const label = typeof children === 'string' ? children : undefined;
|
||||
return (
|
||||
<div role="separator" aria-label={label} className="p-milestone">
|
||||
<span className="p-milestone-line" />
|
||||
<span className="p-milestone-chip">
|
||||
<Icon name="list" size={20} />
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
<span className="p-milestone-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
type Props = { left: number; top: number; width: number; height: number; label: string };
|
||||
|
||||
/** A dashed accent box over the spot Wursor is editing. Position is in preview-stage pixels. */
|
||||
export function PointHighlight({ left, top, width, height, label }: Props) {
|
||||
const style: CSSProperties = { left, top, width, height };
|
||||
return (
|
||||
<div className="p-highlight" style={style} aria-hidden="true">
|
||||
<span className="p-highlight-fill" />
|
||||
<span className="p-highlight-tag">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AgentCursor } from './AgentCursor.tsx';
|
||||
import { BoardStrip } from './BoardStrip.tsx';
|
||||
import { DeviceFrame } from './DeviceFrame.tsx';
|
||||
import { HARBOR } from './harbor-bakery.ts';
|
||||
import { PointHighlight } from './PointHighlight.tsx';
|
||||
import { PreviewToolbar, type PreviewView } from './PreviewToolbar.tsx';
|
||||
import { SiteMock } from './SiteMock.tsx';
|
||||
|
||||
/** The right-hand column of the workspace: view tabs, the live preview of the draft, and the board strip. */
|
||||
export function PreviewColumn({ defaultView = 'preview' }: { defaultView?: PreviewView }) {
|
||||
const [view, setView] = useState<PreviewView>(defaultView);
|
||||
return (
|
||||
<div className="p-previewcol">
|
||||
<PreviewToolbar selected={view} tag="Draft copy" onSelect={setView} />
|
||||
<div className="p-stage">
|
||||
<DeviceFrame url={HARBOR.site.url}>
|
||||
<SiteMock state="after" />
|
||||
</DeviceFrame>
|
||||
<PointHighlight left={409.96} top={312.17} width={152} height={33} label="Editing" />
|
||||
<AgentCursor left={503.76} top={351.17} />
|
||||
</div>
|
||||
<BoardStrip lanes={HARBOR.board} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IconButton } from './IconButton.tsx';
|
||||
import { Tab } from './Tab.tsx';
|
||||
import { Tag } from './Tag.tsx';
|
||||
|
||||
export type PreviewView = 'preview' | 'compare' | 'proof';
|
||||
const TABS: ReadonlyArray<{ value: PreviewView; label: string }> = [
|
||||
{ value: 'preview', label: 'Preview' },
|
||||
{ value: 'compare', label: 'Compare' },
|
||||
{ value: 'proof', label: 'Proof' },
|
||||
];
|
||||
|
||||
type Props = { selected: PreviewView; tag?: string; onSelect?: (view: PreviewView) => void };
|
||||
|
||||
export function PreviewToolbar({ selected, tag, onSelect }: Props) {
|
||||
return (
|
||||
<div className="p-previewbar">
|
||||
<div role="tablist" aria-label="View" className="p-tabs">
|
||||
{TABS.map((t) => (
|
||||
<Tab key={t.value} label={t.label} selected={t.value === selected} onSelect={() => onSelect?.(t.value)} />
|
||||
))}
|
||||
</div>
|
||||
<span className="p-spacer" />
|
||||
{tag && <Tag label={tag} />}
|
||||
<IconButton icon="monitor" label="Desktop" />
|
||||
<IconButton icon="smartphone" label="Phone" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
|
||||
const LABEL = { everyday: 'Everyday' } as const;
|
||||
|
||||
/** Plain-word risk level. Only "Everyday" is designed so far; add the others when their designs exist. */
|
||||
export function RiskChip({ level }: { level: keyof typeof LABEL }) {
|
||||
return (
|
||||
<span className="p-risk">
|
||||
<Icon name="shield-check" size={14} />
|
||||
<span>{LABEL[level]}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
type Option<V extends string> = { value: V; label: string };
|
||||
type Props<V extends string> = {
|
||||
label: string;
|
||||
options: readonly Option<V>[];
|
||||
value: V;
|
||||
onChange: (value: V) => void;
|
||||
};
|
||||
|
||||
/** Three-way (or n-way) choice, for example the play-by-play detail: Headline, Steps, Everything. */
|
||||
export function SegmentedControl<V extends string>({ label, options, value, onChange }: Props<V>) {
|
||||
return (
|
||||
<div role="radiogroup" aria-label={label} className="p-segmented">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={o.value === value}
|
||||
className="p-segment"
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||
import { Avatar } from './Avatar.tsx';
|
||||
import { IconButton } from './IconButton.tsx';
|
||||
import type { Status } from './StatusPill.tsx';
|
||||
|
||||
type Change = { title: string; status: Status; active?: boolean };
|
||||
type Props = {
|
||||
changes: readonly Change[];
|
||||
account: { initials: string; name: string; role: string };
|
||||
/** Where each place goes. Defaults to in-page anchors so a mockup has no real links. */
|
||||
hrefs?: Partial<Record<'Home' | 'Board' | 'Activity' | 'Sites', string>>;
|
||||
};
|
||||
|
||||
const PLACES: ReadonlyArray<{ label: 'Home' | 'Board' | 'Activity' | 'Sites'; icon: IconName }> = [
|
||||
{ label: 'Home', icon: 'home' },
|
||||
{ label: 'Board', icon: 'board' },
|
||||
{ label: 'Activity', icon: 'activity' },
|
||||
{ label: 'Sites', icon: 'globe' },
|
||||
];
|
||||
|
||||
export function Sidebar({ changes, account, hrefs = {} }: Props) {
|
||||
return (
|
||||
<aside className="p-sidebar">
|
||||
<nav aria-label="Workspace" className="p-sidebar-nav">
|
||||
{PLACES.map((p) => (
|
||||
<a key={p.label} href={hrefs[p.label] ?? '#'} className="p-navitem">
|
||||
<Icon name={p.icon} size={18} />
|
||||
<span>{p.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-sidebar-changes-header">
|
||||
<span className="t-overline">Changes</span>
|
||||
<IconButton icon="plus" label="New change" />
|
||||
</div>
|
||||
<ul aria-label="Changes" className="p-changelist">
|
||||
{changes.map((c) => (
|
||||
<li key={c.title} className="p-changeitem" aria-current={c.active ? 'true' : undefined} data-status={c.status}>
|
||||
<span className="p-statusdot" aria-hidden="true" />
|
||||
<span className="p-changeitem-title">{c.title}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span className="p-spacer" />
|
||||
<div className="p-account">
|
||||
<Avatar kind="initials" initials={account.initials} name={account.name} size="md" />
|
||||
<div className="p-account-text">
|
||||
<p>{account.name}</p>
|
||||
<p>{account.role}</p>
|
||||
</div>
|
||||
<IconButton icon="sliders" label="Account settings" />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
import { HARBOR } from './harbor-bakery.ts';
|
||||
|
||||
/**
|
||||
* Harbor Bakery contact page, drawn at its design width of 760 px. Before the change the phone number is
|
||||
* plain text; after it, a tap-to-call link with a 44 px touch target.
|
||||
*/
|
||||
export function SiteMock({ state }: { state: 'before' | 'after' }) {
|
||||
const { phone } = HARBOR;
|
||||
return (
|
||||
<div className="p-site" data-state={state}>
|
||||
<div className="p-site-nav">
|
||||
<p className="p-site-brand">Harbor Bakery</p>
|
||||
<div className="p-site-links">
|
||||
<span>Menu</span>
|
||||
<span>Order</span>
|
||||
<span>About</span>
|
||||
<span className="p-site-current">Contact</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-site-rule" />
|
||||
<div className="p-site-hero">
|
||||
<p className="p-site-title">Find us on Quay Street</p>
|
||||
<p className="p-site-sub">Fresh bread from 6:30, every morning.</p>
|
||||
</div>
|
||||
<div className="p-site-body">
|
||||
<div className="p-site-col">
|
||||
<p className="p-site-label">Opening hours</p>
|
||||
<p className="p-site-line">{'Mon to Fri 6:30 to 17:00'}</p>
|
||||
<p className="p-site-line">{'Saturday 7:00 to 15:00'}</p>
|
||||
<p className="p-site-line">{'Sunday Closed'}</p>
|
||||
</div>
|
||||
<div className="p-site-col">
|
||||
<p className="p-site-label">Call or visit</p>
|
||||
<p className="p-site-line">12 Quay Street</p>
|
||||
{state === 'after' ? (
|
||||
<a href={phone.href} className="p-site-phone">
|
||||
<Icon name="smartphone" size={20} />
|
||||
<span>{phone.callLabel}</span>
|
||||
</a>
|
||||
) : (
|
||||
<p className="p-site-phone-plain">{phone.display}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-site-footer">
|
||||
<span>12 Quay Street</span>
|
||||
<span className={state === 'after' ? 'p-site-footer-link' : undefined}>{phone.display}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type Status = 'idea' | 'working' | 'needs-you' | 'done';
|
||||
|
||||
export const STATUS_LABEL: Record<Status, string> = {
|
||||
idea: 'Idea',
|
||||
working: 'Working',
|
||||
'needs-you': 'Needs you',
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
/** Where a change is, in plain words. The colour is never the only signal; the label is always shown. */
|
||||
export function StatusPill({ status, dot = true }: { status: Status; dot?: boolean }) {
|
||||
return (
|
||||
<span className="p-status" data-status={status}>
|
||||
{dot && <span className="p-dot" aria-hidden="true" />}
|
||||
<span>{STATUS_LABEL[status]}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
import { Counter } from './Counter.tsx';
|
||||
|
||||
type Props = { title: string; count: number; defaultExpanded?: boolean; children?: ReactNode };
|
||||
|
||||
/** Groups a run of similar steps under one line at the Steps detail level. Expand to see each step. */
|
||||
export function StepGroup({ title, count, defaultExpanded = false, children }: Props) {
|
||||
const [open, setOpen] = useState(defaultExpanded);
|
||||
return (
|
||||
<div className="p-stepgroup">
|
||||
<button type="button" className="p-stepgroup-header" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||||
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={20} />
|
||||
<span className="p-stepgroup-title">{title}</span>
|
||||
<Counter count={count} />
|
||||
</button>
|
||||
{open && <div className="p-stepgroup-body">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||
|
||||
type Kind = 'look' | 'search' | 'edit' | 'running';
|
||||
const ICON: Record<Kind, IconName> = { look: 'eye', search: 'search', edit: 'pencil', running: 'loader' };
|
||||
|
||||
/** One thing Wursor did. A running step swaps in a spinner and a stronger label. */
|
||||
export function StepLine({ kind, time, children }: { kind: Kind; time: string; children: ReactNode }) {
|
||||
const running = kind === 'running';
|
||||
return (
|
||||
<div className={`p-stepline${running ? ' p-stepline-running' : ''}`}>
|
||||
{running ? (
|
||||
<span role="status" aria-label="In progress" className="p-stepline-icon">
|
||||
<Icon name={ICON[kind]} size={20} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="p-stepline-icon">
|
||||
<Icon name={ICON[kind]} size={20} />
|
||||
</span>
|
||||
)}
|
||||
<span className="p-stepline-text">{children}</span>
|
||||
<span className="p-stepline-time">{time}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Counter } from './Counter.tsx';
|
||||
|
||||
type Props = { label: string; selected?: boolean; count?: number; onSelect?: () => void };
|
||||
|
||||
/** A tab with an underline indicator for the selected one. Put tabs inside an element with role="tablist". */
|
||||
export function Tab({ label, selected = false, count, onSelect }: Props) {
|
||||
return (
|
||||
<button type="button" role="tab" aria-selected={selected} className="p-tab" onClick={onSelect}>
|
||||
<span className="p-tab-row">
|
||||
<span>{label}</span>
|
||||
{count !== undefined && <Counter count={count} />}
|
||||
</span>
|
||||
<span className="p-tab-indicator" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
|
||||
type Props = { label: string; closable?: boolean; onClose?: () => void };
|
||||
|
||||
/** Small outlined label, optionally removable. */
|
||||
export function Tag({ label, closable = false, onClose }: Props) {
|
||||
return (
|
||||
<span className="p-tag">
|
||||
<span>{label}</span>
|
||||
{closable && (
|
||||
<button type="button" aria-label={`Remove ${label}`} className="p-tag-close" onClick={onClose}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
|
||||
type Props = { seconds: number; defaultExpanded?: boolean; children?: ReactNode };
|
||||
|
||||
/** Collapsed by default. Shows that Wursor reasoned; expand to read the reasoning in plain words. */
|
||||
export function Thought({ seconds, defaultExpanded = false, children }: Props) {
|
||||
const [open, setOpen] = useState(defaultExpanded);
|
||||
return (
|
||||
<div className="p-thought">
|
||||
<button type="button" className="p-thought-header" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||||
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={20} />
|
||||
<span>Thought for {seconds} seconds</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="p-thought-body">
|
||||
<span className="p-thought-rule" aria-hidden="true" />
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Icon } from '../ui/Icon.tsx';
|
||||
import { Avatar } from './Avatar.tsx';
|
||||
import { Button } from './Button.tsx';
|
||||
import { IconButton } from './IconButton.tsx';
|
||||
import { StatusPill, type Status } from './StatusPill.tsx';
|
||||
|
||||
type Props = {
|
||||
site: string;
|
||||
siteInitial: string;
|
||||
change: string;
|
||||
status: Status;
|
||||
autonomy: string;
|
||||
account: { initials: string; name: string };
|
||||
};
|
||||
|
||||
/** Workspace top bar (48 px): sidebar toggle, site switcher, the current change and its status, autonomy, search, notifications, account. */
|
||||
export function TopBar({ site, siteInitial, change, status, autonomy, account }: Props) {
|
||||
return (
|
||||
<header className="p-topbar">
|
||||
<IconButton icon="sidebar" label="Toggle sidebar" />
|
||||
<button type="button" className="p-site-switcher">
|
||||
<span className="p-favicon" aria-hidden="true">
|
||||
{siteInitial}
|
||||
</span>
|
||||
<span>{site}</span>
|
||||
<Icon name="chevron-down" size={20} />
|
||||
</button>
|
||||
<span className="p-spacer" />
|
||||
<div className="p-topbar-change">
|
||||
<span className="p-topbar-change-title">{change}</span>
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
<span className="p-spacer" />
|
||||
<Button variant="ghost" trailingIcon="chevron-down">
|
||||
{autonomy}
|
||||
</Button>
|
||||
<IconButton icon="search" label="Search" />
|
||||
<IconButton icon="bell" label="Notifications" />
|
||||
<Avatar kind="initials" initials={account.initials} name={account.name} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ConversationPanel, type Density } from './ConversationPanel.tsx';
|
||||
import { HARBOR } from './harbor-bakery.ts';
|
||||
import { HarborStream } from './HarborStream.tsx';
|
||||
import { PreviewColumn } from './PreviewColumn.tsx';
|
||||
import { Sidebar } from './Sidebar.tsx';
|
||||
import { TopBar } from './TopBar.tsx';
|
||||
|
||||
/** The workspace at its design size of 1440 × 900: top bar, sidebar, conversation, preview and board. */
|
||||
export function Workspace({ defaultDensity = 'steps' }: { defaultDensity?: Density }) {
|
||||
const [density, setDensity] = useState<Density>(defaultDensity);
|
||||
return (
|
||||
<div className="p-workspace">
|
||||
<TopBar
|
||||
site={HARBOR.site.name}
|
||||
siteInitial={HARBOR.site.initial}
|
||||
change={HARBOR.change.title}
|
||||
status={HARBOR.change.status}
|
||||
autonomy={HARBOR.autonomy}
|
||||
account={HARBOR.account}
|
||||
/>
|
||||
<div className="p-workspace-body">
|
||||
<Sidebar changes={HARBOR.changes} account={HARBOR.account} />
|
||||
<ConversationPanel density={density} onDensityChange={setDensity}>
|
||||
<HarborStream density={density} />
|
||||
</ConversationPanel>
|
||||
<PreviewColumn />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Status } from './StatusPill.tsx';
|
||||
|
||||
/** Harbor Bakery: the fictional example site used for every mockup. Real previews replace this at build time. */
|
||||
export type BoardCardData = {
|
||||
title: string;
|
||||
meta: string;
|
||||
status: Status;
|
||||
risk?: 'everyday';
|
||||
/** 0 to 1, shown on a working change. */
|
||||
progress?: number;
|
||||
};
|
||||
export type BoardLaneData = { status: Status; title: string; cards: BoardCardData[] };
|
||||
|
||||
export const HARBOR = {
|
||||
site: { name: 'Harbor Bakery', initial: 'H', url: 'harborbakery.com/contact' },
|
||||
account: { initials: 'AO', name: 'Amara Okoye', role: 'Owner' },
|
||||
autonomy: 'Ask first',
|
||||
change: { title: 'Make phone number tappable', status: 'working' as Status },
|
||||
changes: [
|
||||
{ title: 'Make phone number tappable', status: 'working' as Status, active: true },
|
||||
{ title: 'Update opening hours', status: 'needs-you' as Status },
|
||||
{ title: 'Describe 3 images', status: 'idea' as Status },
|
||||
{ title: 'Refresh footer links', status: 'done' as Status },
|
||||
],
|
||||
phone: { display: '(555) 014-2290', href: 'tel:+55550142290', callLabel: 'Call (555) 014-2290' },
|
||||
board: [
|
||||
{
|
||||
status: 'needs-you',
|
||||
title: 'Needs you',
|
||||
cards: [{ title: 'Update opening hours', meta: 'Home page · 1 hour ago', status: 'needs-you' }],
|
||||
},
|
||||
{
|
||||
status: 'working',
|
||||
title: 'Working',
|
||||
cards: [
|
||||
{ title: 'Make phone number tappable', meta: 'Contact page · 1 min ago', status: 'working', risk: 'everyday', progress: 0.42 },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: 'idea',
|
||||
title: 'Idea',
|
||||
cards: [{ title: 'Describe 3 images', meta: 'Home page · Suggested', status: 'idea' }],
|
||||
},
|
||||
{
|
||||
status: 'done',
|
||||
title: 'Done',
|
||||
cards: [{ title: 'Refresh footer links', meta: 'Footer · Yesterday', status: 'done' }],
|
||||
},
|
||||
] as BoardLaneData[],
|
||||
} as const;
|
||||
@@ -0,0 +1,35 @@
|
||||
export { AgentCursor } from './AgentCursor.tsx';
|
||||
export { ApprovalBar } from './ApprovalBar.tsx';
|
||||
export { ApprovalCard } from './ApprovalCard.tsx';
|
||||
export { Avatar } from './Avatar.tsx';
|
||||
export { BoardCard } from './BoardCard.tsx';
|
||||
export { BoardStrip } from './BoardStrip.tsx';
|
||||
export { Button } from './Button.tsx';
|
||||
export { ChangeEvent } from './ChangeEvent.tsx';
|
||||
export { CheckEvent } from './CheckEvent.tsx';
|
||||
export { CompareSlider } from './CompareSlider.tsx';
|
||||
export { Composer } from './Composer.tsx';
|
||||
export { ConversationPanel, type Density } from './ConversationPanel.tsx';
|
||||
export { Counter } from './Counter.tsx';
|
||||
export { DeviceFrame } from './DeviceFrame.tsx';
|
||||
export { HARBOR } from './harbor-bakery.ts';
|
||||
export { HarborStream } from './HarborStream.tsx';
|
||||
export { IconButton } from './IconButton.tsx';
|
||||
export { Message } from './Message.tsx';
|
||||
export { Milestone } from './Milestone.tsx';
|
||||
export { HarborConversation } from './HarborConversation.tsx';
|
||||
export { PointHighlight } from './PointHighlight.tsx';
|
||||
export { PreviewColumn } from './PreviewColumn.tsx';
|
||||
export { PreviewToolbar, type PreviewView } from './PreviewToolbar.tsx';
|
||||
export { RiskChip } from './RiskChip.tsx';
|
||||
export { SegmentedControl } from './SegmentedControl.tsx';
|
||||
export { Sidebar } from './Sidebar.tsx';
|
||||
export { SiteMock } from './SiteMock.tsx';
|
||||
export { StatusPill, type Status } from './StatusPill.tsx';
|
||||
export { StepGroup } from './StepGroup.tsx';
|
||||
export { StepLine } from './StepLine.tsx';
|
||||
export { Tab } from './Tab.tsx';
|
||||
export { Tag } from './Tag.tsx';
|
||||
export { Thought } from './Thought.tsx';
|
||||
export { TopBar } from './TopBar.tsx';
|
||||
export { Workspace } from './Workspace.tsx';
|
||||
@@ -115,3 +115,186 @@ button { font: inherit; color: inherit; }
|
||||
.footer-column { flex: 1 1 40%; }
|
||||
.footer-bottom { flex-wrap: wrap; gap: var(--space-3) var(--space-6); }
|
||||
}
|
||||
|
||||
/* ---------- Button variants ---------- */
|
||||
.btn-secondary { background: var(--color-bg-surface); border: 1px solid var(--color-border-default); color: var(--color-text-primary); }
|
||||
.btn-light { background: var(--color-bg-canvas); color: var(--color-text-primary); }
|
||||
.btn-ghost:hover { background: var(--color-bg-sunken); }
|
||||
|
||||
/* ---------- Page rhythm ---------- */
|
||||
main { display: block; }
|
||||
.m-section {
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--space-10);
|
||||
max-width: 1440px; margin: 160px auto 0; padding: 0 var(--space-6);
|
||||
}
|
||||
.m-section-first { margin-top: 140px; gap: var(--space-8); }
|
||||
.m-section-header { display: flex; flex-direction: column; gap: var(--space-4); width: 100%; max-width: 720px; }
|
||||
.m-align-center { align-items: center; text-align: center; }
|
||||
.m-align-left { align-items: flex-start; text-align: left; }
|
||||
.m-section-header > *, .m-section-title { margin: 0; }
|
||||
.m-eyebrow { color: var(--color-accent-text); }
|
||||
.m-section-title { color: var(--color-text-primary); }
|
||||
.m-section-sub { margin: 0; color: var(--color-text-secondary); }
|
||||
.m-caption { text-align: center; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.m-hero { display: flex; flex-direction: column; align-items: center; gap: var(--space-6); padding: var(--space-4) var(--space-6) 0; text-align: center; }
|
||||
.m-announce {
|
||||
display: inline-flex; align-items: center; gap: var(--space-2); padding: 7px 15px 7px 7px;
|
||||
border: 1px solid var(--color-border-default); border-radius: var(--radius-full); background: var(--color-bg-surface);
|
||||
}
|
||||
.m-announce-new { display: inline-flex; align-items: center; height: 20px; padding: 0 10px; border-radius: var(--radius-full); background: var(--color-accent-subtle); color: var(--color-accent-text); }
|
||||
.m-hero-title { max-width: 1000px; margin: 0; color: var(--color-text-primary); }
|
||||
.m-hero-sub { max-width: 560px; margin: 0; color: var(--color-text-secondary); }
|
||||
.m-hero-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3); }
|
||||
.m-hero-footnote { margin: 0; color: var(--color-text-tertiary); }
|
||||
.m-hero-visual { width: 100%; max-width: 1344px; }
|
||||
.m-visual { position: relative; width: 100%; aspect-ratio: 1344 / 700; overflow: hidden; border-radius: var(--radius-xl); }
|
||||
.m-backdrop { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.m-hero-shot { position: absolute; left: 9.0774%; top: 11.4286%; width: 81.8452%; }
|
||||
|
||||
/* ---------- Proof strip ---------- */
|
||||
.m-proof { display: flex; gap: var(--space-8); max-width: 1200px; margin: 96px auto 0; padding: 0 var(--space-6); text-align: center; }
|
||||
.m-proof-stat { display: flex; flex: 1 0 0; flex-direction: column; align-items: center; gap: var(--space-2); }
|
||||
.m-proof-stat p { margin: 0; }
|
||||
|
||||
/* ---------- Surfaces ---------- */
|
||||
.m-showcase { display: flex; flex-direction: column; align-items: center; gap: var(--space-8); width: 100%; max-width: 1344px; }
|
||||
.m-feature-tabs { display: flex; gap: var(--space-2); }
|
||||
.m-feature-tab {
|
||||
display: inline-flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-4); cursor: pointer;
|
||||
border-radius: var(--radius-full); background: var(--color-bg-surface); border: 1px solid var(--color-border-default); color: var(--color-text-primary);
|
||||
}
|
||||
.m-feature-tab[aria-selected="true"] { background: var(--color-bg-inverse); border-color: var(--color-bg-inverse); color: var(--color-text-inverse); }
|
||||
.m-showcase-panel { display: flex; flex-direction: column; align-items: center; gap: var(--space-8); width: 100%; }
|
||||
.m-surface { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; aspect-ratio: 1344 / 720; overflow: hidden; border-radius: var(--radius-xl); text-align: left; }
|
||||
.m-window { position: relative; width: var(--w); overflow: hidden; border-radius: var(--radius-lg); background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); box-shadow: var(--shadow-modal); }
|
||||
.m-m { display: none; }
|
||||
|
||||
/* ---------- Watch the work ---------- */
|
||||
.m-densities { display: grid; grid-template-columns: repeat(3, 440px); gap: var(--space-6); justify-content: center; max-width: 100%; }
|
||||
.m-density { display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; }
|
||||
.m-density-head { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.m-density-head > * { margin: 0; }
|
||||
.m-density-head h3 { color: var(--color-text-primary); }
|
||||
.m-crop { position: relative; width: 440px; max-width: 100%; overflow: hidden; border-radius: var(--radius-xl); background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); }
|
||||
.m-crop-frame { position: relative; width: 440px; height: 600px; overflow: hidden; }
|
||||
.m-crop-shot { position: absolute; left: -249px; top: -49px; width: 1440px; height: 900px; }
|
||||
|
||||
/* ---------- Approve with proof ---------- */
|
||||
.m-approve { display: flex; align-items: center; gap: var(--space-16); max-width: 1248px; margin: 160px auto 0; padding: 0 var(--space-6); }
|
||||
.m-approve-text { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; align-items: flex-start; gap: var(--space-6); }
|
||||
.m-approve-text .btn { align-self: flex-start; }
|
||||
.m-points { display: flex; flex-direction: column; gap: var(--space-3); margin: 0; padding: 0; list-style: none; }
|
||||
.m-points li { display: flex; align-items: center; gap: var(--space-3); color: var(--color-text-primary); }
|
||||
.m-points .icon { color: var(--color-status-success-fg); }
|
||||
.m-approve-visual { position: relative; flex: none; width: 600px; height: 700px; overflow: hidden; border-radius: var(--radius-xl); }
|
||||
.m-approve-backdrop { inset: auto; top: 0; left: -500px; width: 1344px; height: 720px; max-width: none; }
|
||||
.m-approve-card { position: absolute; left: 10%; top: 130px; width: 80%; }
|
||||
|
||||
/* ---------- Safe by design ---------- */
|
||||
.m-rules { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-6); width: 100%; max-width: 1192px; }
|
||||
.m-rule { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-6); border-radius: var(--radius-xl); background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); }
|
||||
.m-rule h3, .m-rule p { margin: 0; }
|
||||
.m-rule h3 { color: var(--color-text-primary); }
|
||||
.m-rule-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: var(--radius-md); background: var(--color-accent-subtle); color: var(--color-accent-text); }
|
||||
|
||||
/* ---------- Examples ---------- */
|
||||
.m-section:has(.m-carousel) { gap: 56px; padding: 0; }
|
||||
.m-section:has(.m-carousel) .m-section-header { padding: 0 var(--space-6); }
|
||||
.m-carousel {
|
||||
display: flex; gap: var(--space-6); width: 100%; margin: 0; list-style: none; overflow-x: auto; scroll-snap-type: x proximity;
|
||||
padding: 0 var(--space-6) var(--space-2) max(var(--space-6), calc((100vw - 1200px) / 2)); scrollbar-width: none;
|
||||
}
|
||||
.m-carousel::-webkit-scrollbar { display: none; }
|
||||
.m-example { display: flex; flex: none; flex-direction: column; gap: var(--space-3); width: 300px; padding: var(--space-4); scroll-snap-align: start; border-radius: var(--radius-xl); background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); }
|
||||
.m-example p { margin: 0; }
|
||||
.m-example > .t-title-s { color: var(--color-text-primary); }
|
||||
.m-thumbs { display: flex; gap: var(--space-2); }
|
||||
.m-thumb { position: relative; flex: none; width: 130px; height: 92px; overflow: hidden; border-radius: var(--radius-md); border: 1px solid var(--color-border-subtle); }
|
||||
.m-thumb-label { position: absolute; top: 5px; left: 5px; padding: 2px 6px; border-radius: var(--radius-xs); background: var(--color-bg-surface); color: var(--color-text-primary); }
|
||||
.m-thumb[data-state="after"] .m-thumb-label { background: var(--color-bg-inverse); color: var(--color-text-inverse); }
|
||||
.m-example-meta { display: flex; align-items: center; gap: var(--space-2); color: var(--color-text-secondary); }
|
||||
|
||||
/* ---------- Pricing ---------- */
|
||||
.m-pricing-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3); }
|
||||
.m-plans { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-6); width: 100%; max-width: 1200px; }
|
||||
.m-plan { display: flex; flex-direction: column; align-items: stretch; gap: var(--space-4); padding: var(--space-6); border-radius: var(--radius-xl); background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); }
|
||||
.m-plan-featured { border: 2px solid var(--color-accent-default); padding: calc(var(--space-6) - 1px); }
|
||||
.m-plan-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.m-plan h3, .m-plan p { margin: 0; }
|
||||
.m-plan-tag { display: inline-flex; align-items: center; height: 28px; padding: 0 var(--space-3); border-radius: var(--radius-full); background: var(--color-bg-inverse); color: var(--color-text-inverse); }
|
||||
.m-plan-price { display: flex; align-items: flex-end; gap: var(--space-1); }
|
||||
.m-plan-price .t-caption { color: var(--color-text-tertiary); }
|
||||
.m-plan-features { display: flex; flex-direction: column; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
|
||||
.m-plan-features li { display: flex; align-items: flex-start; gap: var(--space-2); color: var(--color-text-primary); }
|
||||
.m-plan-features .icon { color: var(--color-status-success-fg); }
|
||||
.m-plan-cta { width: 100%; }
|
||||
|
||||
/* ---------- FAQ ---------- */
|
||||
.m-faq { width: 100%; max-width: 760px; }
|
||||
.m-faq-item { padding: var(--space-5) 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.m-faq-question { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); cursor: pointer; list-style: none; color: var(--color-text-primary); }
|
||||
.m-faq-question::-webkit-details-marker { display: none; }
|
||||
.m-faq-question > span:first-child { flex: 1 0 0; }
|
||||
.m-faq-chevron { color: var(--color-text-tertiary); transition: transform 0.15s ease; }
|
||||
.m-faq-item[open] .m-faq-chevron { transform: rotate(180deg); }
|
||||
.m-faq-item p { margin-top: var(--space-3); }
|
||||
|
||||
/* ---------- Closing CTA ---------- */
|
||||
.m-cta { max-width: 1248px; margin: 160px auto 0; padding: 0 var(--space-6); }
|
||||
.m-cta-panel { display: flex; flex-direction: column; align-items: center; gap: var(--space-6); padding: 96px 80px; border-radius: 32px; background: var(--color-bg-inverse); text-align: center; }
|
||||
.m-cta-title { max-width: 760px; margin: 0; color: var(--color-text-inverse); }
|
||||
.m-cta-sub { margin: 0; color: var(--color-text-inverse); opacity: 0.8; }
|
||||
.footer { margin-top: 160px; }
|
||||
.m-carousel { scroll-padding-inline-start: max(var(--space-6), calc((100vw - 1200px) / 2)); }
|
||||
|
||||
/* ---------- Phone layout (design: 390 px wide) ---------- */
|
||||
@media (max-width: 900px) {
|
||||
.m-d { display: none; }
|
||||
.m-m { display: inline; }
|
||||
.m-hide-mobile { display: none; }
|
||||
|
||||
.t-display-xl, .m-hero-title { font: 300 36px/40px var(--font-sans); letter-spacing: -0.02em; }
|
||||
.m-section-title { font: 300 36px/40px var(--font-sans); letter-spacing: -0.02em; }
|
||||
.m-section { margin-top: 72px; gap: var(--space-8); padding: 0 var(--space-4); }
|
||||
.m-section-first { margin-top: 72px; }
|
||||
.m-section:has(.m-carousel) { padding: 0; gap: var(--space-8); }
|
||||
.m-section:has(.m-carousel) .m-section-header { padding: 0 var(--space-4); }
|
||||
|
||||
.m-hero { padding: var(--space-8) var(--space-4) 0; gap: var(--space-5); }
|
||||
.m-announce { display: none; }
|
||||
.m-hero-actions { flex-direction: column; align-items: stretch; width: 100%; }
|
||||
.m-hero-actions .btn { width: 100%; }
|
||||
.m-hero-footnote { display: none; }
|
||||
.m-hero-visual { margin-top: var(--space-2); }
|
||||
.m-visual { aspect-ratio: auto; height: 420px; border-radius: 28px; }
|
||||
.m-hero-shot { left: 24px; right: 24px; top: 40px; width: auto; height: 330px; overflow: hidden; border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
|
||||
|
||||
.m-proof { flex-direction: column; gap: var(--space-6); margin-top: 56px; padding: 0 var(--space-4); }
|
||||
|
||||
.m-feature-tabs { gap: var(--space-1); justify-content: center; }
|
||||
.m-surface { aspect-ratio: auto; height: 390px; align-items: flex-start; padding-top: 32px; border-radius: 28px; }
|
||||
.m-window { width: calc(100% - 48px); }
|
||||
.m-showcase-panel { gap: var(--space-6); }
|
||||
|
||||
.m-densities { display: flex; justify-content: flex-start; gap: var(--space-4); width: 100%; margin: 0 calc(-1 * var(--space-4)); padding: 0 var(--space-4); overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--space-4); scrollbar-width: none; }
|
||||
.m-densities::-webkit-scrollbar { display: none; }
|
||||
.m-density { flex: none; width: calc(100vw - 32px); scroll-snap-align: start; }
|
||||
.m-crop { width: 100%; }
|
||||
|
||||
.m-approve { flex-direction: column; align-items: stretch; gap: var(--space-8); margin-top: 72px; padding: 0 var(--space-4); }
|
||||
.m-approve-visual { width: 100%; height: auto; aspect-ratio: 358 / 440; border-radius: 28px; }
|
||||
.m-approve-card { left: 4%; width: 92%; top: 40px; }
|
||||
.m-approve-backdrop { left: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.m-rules { grid-template-columns: 1fr; }
|
||||
.m-plans { grid-template-columns: 1fr; }
|
||||
.m-faq-item { padding: var(--space-5) 0; }
|
||||
|
||||
.m-cta { margin-top: 72px; padding: 0 var(--space-4); }
|
||||
.m-cta-panel { padding: 40px var(--space-6); border-radius: 24px; }
|
||||
.m-cta-title { font: 400 28px/34px var(--font-sans); letter-spacing: -0.015em; }
|
||||
.m-cta-sub { display: none; }
|
||||
.footer { margin-top: 72px; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Product UI components, built once at their design size (1x) from the Figma file "Wursor - weown.net".
|
||||
* The marketing mockups scale these with Fit/Zoom; the app uses them as they are.
|
||||
* Colours, spacing and radii come from tokens.css only.
|
||||
*/
|
||||
.p-workspace, .p-approvalcard, .p-convo, .p-previewcol, .p-board, .p-site { text-align: left; }
|
||||
.p-spacer { flex: 1 0 0; min-width: 1px; min-height: 1px; }
|
||||
button:where([class^="p-"]) { font: inherit; }
|
||||
button:where([class^="p-"]), :where(.p-compare-handle) { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0; }
|
||||
button:where([class^="p-"]):focus-visible { outline: 2px solid var(--color-accent-default); outline-offset: 2px; }
|
||||
.p-workspace p, .p-approvalcard p, .p-convo p, .p-site p, .p-board p { margin: 0; }
|
||||
.p-workspace h2, .p-workspace h3 { margin: 0; font: inherit; }
|
||||
|
||||
/* ---------- Avatar ---------- */
|
||||
.p-avatar { display: inline-flex; flex: none; align-items: center; justify-content: center; border-radius: var(--radius-full); }
|
||||
.p-avatar-sm { width: 24px; height: 24px; }
|
||||
.p-avatar-md { width: 32px; height: 32px; }
|
||||
.p-avatar-initials { background: var(--color-bg-sunken); border: 1px solid var(--color-border-subtle); color: var(--color-text-primary); }
|
||||
.p-avatar-initials.p-avatar-sm { font: 500 12px/16px var(--font-sans); }
|
||||
.p-avatar-initials.p-avatar-md { font: 500 13px/16px var(--font-sans); }
|
||||
.p-avatar-wursor { background: var(--color-bg-inverse); }
|
||||
.p-avatar-mark { display: block; width: 14px; height: 14px; }
|
||||
.p-avatar-mark-dark { display: none; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .p-avatar-mark-light { display: none; }
|
||||
:root:not([data-theme="light"]) .p-avatar-mark-dark { display: block; }
|
||||
}
|
||||
:root[data-theme="dark"] .p-avatar-mark-light { display: none; }
|
||||
:root[data-theme="dark"] .p-avatar-mark-dark { display: block; }
|
||||
|
||||
/* ---------- Counter, status, risk ---------- */
|
||||
.p-counter {
|
||||
display: inline-flex; align-items: center; justify-content: center; flex: none;
|
||||
height: 18px; padding: 0 var(--space-1); border-radius: var(--radius-full);
|
||||
background: var(--color-accent-fill); color: var(--color-text-on-accent); font: 500 12px/16px var(--font-sans);
|
||||
}
|
||||
.p-status {
|
||||
display: inline-flex; align-items: center; flex: none; gap: var(--space-2); height: 24px;
|
||||
padding: 0 var(--space-3) 0 var(--space-2); border-radius: var(--radius-full);
|
||||
background: var(--color-bg-sunken); color: var(--color-text-secondary); font: 500 12px/16px var(--font-sans); white-space: nowrap;
|
||||
}
|
||||
.p-status[data-status="working"] { background: var(--color-status-working-bg); color: var(--color-status-working-fg); }
|
||||
.p-status[data-status="needs-you"] { background: var(--color-status-needs-you-bg); color: var(--color-status-needs-you-fg); }
|
||||
.p-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-text-tertiary); }
|
||||
.p-status[data-status="working"] .p-dot { background: var(--color-status-working-dot); }
|
||||
.p-status[data-status="needs-you"] .p-dot { background: var(--color-status-needs-you-dot); }
|
||||
.p-risk {
|
||||
display: inline-flex; align-items: center; flex: none; gap: var(--space-1); height: 24px;
|
||||
padding: 0 var(--space-3) 0 var(--space-2); border-radius: var(--radius-full);
|
||||
background: var(--color-bg-surface); border: 1px solid var(--color-border-default);
|
||||
color: var(--color-text-primary); font: 500 12px/16px var(--font-sans); white-space: nowrap;
|
||||
}
|
||||
.p-risk .icon { color: var(--color-status-success-fg); }
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.p-iconbtn { display: inline-flex; flex: none; align-items: center; justify-content: center; border-radius: var(--radius-full); color: var(--icon-color); }
|
||||
.p-iconbtn-sm { width: 28px; height: 28px; }
|
||||
.p-iconbtn-md { width: 36px; height: 36px; }
|
||||
.p-iconbtn-inverse { background: var(--color-bg-inverse); color: var(--color-text-inverse); }
|
||||
.p-iconbtn-ghost:hover { background: var(--color-bg-sunken); }
|
||||
.p-btn {
|
||||
display: inline-flex; flex: none; align-items: center; justify-content: center; gap: var(--space-2);
|
||||
border-radius: var(--radius-full); font: 500 13px/16px var(--font-sans); white-space: nowrap;
|
||||
}
|
||||
.p-btn-sm { height: 28px; padding: 0 var(--space-3); }
|
||||
.p-btn-md { height: 36px; padding: 0 var(--space-4); }
|
||||
.p-btn-primary { background: var(--color-bg-inverse); color: var(--color-text-inverse); }
|
||||
.p-btn-secondary { background: var(--color-bg-surface); border: 1px solid var(--color-border-default); color: var(--color-text-primary); }
|
||||
.p-btn-ghost { color: var(--color-text-primary); }
|
||||
.p-btn-ghost:hover { background: var(--color-bg-sunken); }
|
||||
|
||||
/* ---------- Segmented control, tabs, tag ---------- */
|
||||
.p-segmented {
|
||||
display: inline-flex; gap: 2px; padding: 2px; border-radius: var(--radius-full);
|
||||
background: var(--color-bg-sunken); border: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.p-segment {
|
||||
display: inline-flex; align-items: center; justify-content: center; height: 28px; padding: 0 var(--space-3);
|
||||
border: 1px solid transparent; border-radius: var(--radius-full); color: var(--color-text-secondary); font: 500 13px/16px var(--font-sans);
|
||||
}
|
||||
.p-segment[aria-checked="true"] { background: var(--color-bg-surface); border-color: var(--color-border-subtle); color: var(--color-text-primary); }
|
||||
.p-tabs { display: flex; gap: var(--space-1); }
|
||||
.p-tab { display: flex; flex-direction: column; align-items: stretch; color: var(--color-text-secondary); font: 500 13px/16px var(--font-sans); }
|
||||
.p-tab[aria-selected="true"] { color: var(--color-text-primary); }
|
||||
.p-tab-row { display: flex; align-items: center; justify-content: center; gap: var(--space-1); padding: var(--space-2) var(--space-3); }
|
||||
.p-tab-indicator { height: 2px; }
|
||||
.p-tab[aria-selected="true"] .p-tab-indicator { background: var(--color-bg-inverse); }
|
||||
.p-tag {
|
||||
display: inline-flex; align-items: center; flex: none; gap: var(--space-1); height: 28px; padding: 0 var(--space-3);
|
||||
border-radius: var(--radius-full); background: var(--color-bg-surface); border: 1px solid var(--color-border-default);
|
||||
color: var(--color-text-primary); font: 500 13px/16px var(--font-sans); white-space: nowrap;
|
||||
}
|
||||
.p-tag-close { display: inline-flex; color: var(--icon-color); }
|
||||
|
||||
/* ---------- Conversation ---------- */
|
||||
.p-convo { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; background: var(--color-bg-surface); border-right: 1px solid var(--color-border-subtle); }
|
||||
.p-convo-header { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); }
|
||||
.p-convo-title { font: 500 13px/16px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-stream { display: flex; flex: 1 0 0; flex-direction: column; align-items: stretch; gap: var(--space-4); min-height: 0; padding: var(--space-4); overflow: hidden; }
|
||||
.p-stream[data-density="everything"] { justify-content: flex-end; }
|
||||
.p-composer-area { padding: var(--space-2) var(--space-4) var(--space-4); }
|
||||
.p-message-user { padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-bg-sunken); }
|
||||
.p-message p { font: 400 14px/20px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-message-wursor { display: flex; align-items: flex-start; gap: var(--space-3); }
|
||||
.p-message-wursor p { flex: 1 0 0; min-width: 0; }
|
||||
.p-stepgroup-header, .p-thought-header { display: flex; align-items: center; gap: var(--space-1); width: 100%; text-align: left; }
|
||||
.p-stepgroup-header { font: 500 13px/16px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-stepgroup-title { flex: 1 0 0; min-width: 0; }
|
||||
.p-stepgroup-header .icon, .p-thought-header .icon { color: var(--color-text-tertiary); }
|
||||
.p-stepgroup-body { padding-left: var(--space-6); }
|
||||
.p-thought { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.p-thought-header { font: 500 12px/16px var(--font-sans); color: var(--color-text-tertiary); }
|
||||
.p-thought-body { display: flex; align-items: stretch; gap: var(--space-3); padding-left: 10px; }
|
||||
.p-thought-rule { flex: none; width: 2px; background: var(--color-border-default); }
|
||||
.p-thought-body p { flex: 1 0 0; min-width: 0; font: 400 14px/20px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-stepline { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-1) 0; }
|
||||
.p-stepline-icon { display: inline-flex; flex: none; color: var(--color-text-secondary); }
|
||||
.p-stepline-running .p-stepline-icon { color: var(--color-status-working-dot); }
|
||||
.p-stepline-text { flex: 1 0 0; min-width: 0; font: 400 14px/20px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-stepline-running .p-stepline-text { color: var(--color-text-primary); }
|
||||
.p-stepline-time { flex: none; font: 400 12px/16px var(--font-sans); color: var(--color-text-tertiary); white-space: nowrap; }
|
||||
.p-change { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-4); border-radius: var(--radius-md); background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); }
|
||||
.p-change-top, .p-change-diff { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.p-change-tile { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: var(--radius-sm); background: var(--color-bg-sunken); color: var(--color-text-secondary); }
|
||||
.p-change-text { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; }
|
||||
.p-change-title { font: 500 14px/20px var(--font-sans); color: var(--color-text-primary); white-space: nowrap; }
|
||||
.p-change-meta { font: 400 12px/16px var(--font-sans); color: var(--color-text-tertiary); white-space: nowrap; }
|
||||
.p-diff-add { font: 400 12px/16px var(--font-mono); color: var(--color-diff-add-fg); white-space: nowrap; }
|
||||
.p-diff-remove { font: 400 12px/16px var(--font-mono); color: var(--color-diff-remove-fg); white-space: nowrap; }
|
||||
.p-check { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-4); border-radius: var(--radius-md); background: var(--color-status-success-bg); }
|
||||
.p-check-icon { display: inline-flex; flex: none; color: var(--color-status-success-fg); }
|
||||
.p-check-text { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; gap: var(--space-1); }
|
||||
.p-check-title { font: 500 14px/20px var(--font-sans); color: var(--color-status-success-fg); }
|
||||
.p-check-detail { font: 400 12px/16px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-check > .icon { flex: none; color: var(--color-text-secondary); }
|
||||
.p-milestone { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; }
|
||||
.p-milestone-line { flex: 1 0 0; height: 1px; background: var(--color-border-subtle); }
|
||||
.p-milestone-chip { display: inline-flex; align-items: center; gap: var(--space-1); font: 500 12px/16px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-milestone-chip .icon { color: var(--color-text-secondary); }
|
||||
.p-composer { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-4); border-radius: var(--radius-xl); background: var(--color-bg-surface); border: 1px solid var(--color-border-default); }
|
||||
.p-composer-input { width: 100%; padding: 0; border: 0; outline: 0; resize: none; background: transparent; color: var(--color-text-primary); font: 400 14px/20px var(--font-sans); }
|
||||
.p-composer-input::placeholder { color: var(--color-text-tertiary); opacity: 1; }
|
||||
.p-composer-toolbar { display: flex; align-items: center; justify-content: space-between; }
|
||||
.p-composer-side { display: flex; align-items: center; gap: var(--space-1); }
|
||||
|
||||
/* ---------- Workspace chrome ---------- */
|
||||
.p-topbar { display: flex; align-items: center; gap: var(--space-3); height: 48px; padding: 0 var(--space-4); background: var(--color-bg-surface); border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.p-site-switcher { display: inline-flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); font: 500 13px/16px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-site-switcher .icon { color: var(--color-text-tertiary); }
|
||||
.p-favicon { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-xs); background: var(--color-accent-subtle); color: var(--color-accent-text); font: 500 12px/16px var(--font-sans); }
|
||||
.p-topbar-change { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.p-topbar-change-title { font: 500 13px/16px var(--font-sans); color: var(--color-text-primary); white-space: nowrap; }
|
||||
.p-sidebar { display: flex; flex: none; flex-direction: column; gap: var(--space-1); width: 240px; height: 100%; padding: var(--space-3); background: var(--color-bg-canvas); border-right: 1px solid var(--color-border-subtle); }
|
||||
.p-sidebar-nav { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.p-navitem { display: flex; align-items: center; gap: var(--space-2); height: 32px; padding: 0 var(--space-2); border-radius: var(--radius-sm); font: 400 14px/20px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-navitem .icon { color: var(--color-text-secondary); }
|
||||
.p-sidebar-changes-header { display: flex; align-items: center; justify-content: space-between; padding: 16px var(--space-2) 0; }
|
||||
.p-sidebar-changes-header .t-overline { color: var(--color-text-tertiary); }
|
||||
.p-changelist { display: flex; flex-direction: column; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
|
||||
.p-changeitem { display: flex; align-items: center; gap: var(--space-2); height: 32px; padding: 0 var(--space-2); border: 1px solid transparent; border-radius: var(--radius-sm); font: 400 14px/20px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-changeitem[aria-current="true"] { background: var(--color-bg-surface); border-color: var(--color-border-subtle); color: var(--color-text-primary); }
|
||||
.p-changeitem-title { flex: 1 0 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.p-statusdot { flex: none; width: 8px; height: 8px; border-radius: 50%; border: 1px solid var(--color-border-strong); }
|
||||
.p-changeitem[data-status="working"] .p-statusdot { background: var(--color-status-working-dot); border-color: transparent; }
|
||||
.p-changeitem[data-status="needs-you"] .p-statusdot { background: var(--color-status-needs-you-dot); border-color: transparent; }
|
||||
.p-changeitem[data-status="done"] .p-statusdot { background: var(--color-status-success-dot); border-color: transparent; }
|
||||
.p-account { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2); }
|
||||
.p-account-text { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; }
|
||||
.p-account-text p:first-child { font: 500 13px/16px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-account-text p:last-child { font: 400 12px/16px var(--font-sans); color: var(--color-text-tertiary); }
|
||||
.p-workspace { position: relative; display: flex; flex-direction: column; width: 1440px; height: 900px; overflow: hidden; background: var(--color-bg-canvas); color: var(--color-text-primary); font-family: var(--font-sans); }
|
||||
.p-workspace-body { display: flex; flex: 1 0 0; min-height: 0; }
|
||||
.p-workspace-body > .p-convo { width: 440px; flex: none; }
|
||||
|
||||
/* ---------- Preview ---------- */
|
||||
.p-previewcol { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; height: 100%; }
|
||||
.p-previewbar { display: flex; align-items: center; gap: var(--space-1); padding: var(--space-2) var(--space-4); }
|
||||
.p-stage { position: relative; display: flex; flex: 1 0 0; min-height: 0; flex-direction: column; align-items: center; padding: var(--space-6); overflow: hidden; background: var(--color-bg-sunken); }
|
||||
.p-device { width: 712px; flex: none; overflow: hidden; border-radius: var(--radius-lg); background: var(--color-bg-surface); }
|
||||
.p-device-scale { width: 760px; zoom: 0.93684; }
|
||||
.p-device-bar { display: flex; align-items: center; gap: var(--space-4); padding: var(--space-2) var(--space-4); background: var(--color-bg-sunken); }
|
||||
.p-device-dots { color: var(--color-border-strong); }
|
||||
.p-device-address { display: flex; flex: 1 0 0; min-width: 0; align-items: center; justify-content: center; gap: var(--space-1); padding: var(--space-1) var(--space-3); border-radius: var(--radius-full); background: var(--color-bg-surface); font: 400 12px/16px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-device-address .icon, .p-device-bar > .icon:last-child { color: var(--color-text-tertiary); }
|
||||
.p-highlight { position: absolute; border: 2px dashed var(--color-accent-default); border-radius: var(--radius-sm); }
|
||||
.p-highlight-fill { position: absolute; inset: -2px; border-radius: var(--radius-sm); background: var(--color-accent-default); opacity: 0.08; }
|
||||
.p-highlight-tag { position: absolute; bottom: -30px; left: -2px; padding: var(--space-1) var(--space-2); border-radius: var(--radius-xs); background: var(--color-accent-fill); color: var(--color-text-on-accent); font: 500 12px/16px var(--font-sans); white-space: nowrap; }
|
||||
.p-cursor { position: absolute; display: flex; align-items: center; gap: var(--space-1); }
|
||||
.p-cursor .icon { color: var(--color-accent-default); }
|
||||
.p-cursor-name { padding: var(--space-1) var(--space-2); border-radius: var(--radius-full); background: var(--color-accent-fill); color: var(--color-text-on-accent); font: 500 12px/16px var(--font-sans); }
|
||||
|
||||
/* ---------- Site mock (Harbor Bakery, 760 px design width) ---------- */
|
||||
.p-site { width: 760px; background: var(--color-bg-surface); font-family: var(--font-sans); }
|
||||
.p-site-nav { display: flex; align-items: center; justify-content: space-between; padding: var(--space-4) var(--space-6); font-weight: 500; }
|
||||
.p-site-brand { font: 500 16px/24px var(--font-sans); letter-spacing: -0.005em; color: var(--color-text-primary); }
|
||||
.p-site-links { display: flex; gap: var(--space-5); font: 500 13px/16px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-site-links .p-site-current { color: var(--color-text-primary); }
|
||||
.p-site-rule { height: 1px; background: var(--color-border-subtle); }
|
||||
.p-site-hero { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-8) var(--space-6); background: var(--color-accent-subtle); }
|
||||
.p-site-title { font: 400 28px/34px var(--font-sans); letter-spacing: -0.015em; color: var(--color-text-primary); }
|
||||
.p-site-sub { font: 400 14px/20px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-site-body { display: flex; align-items: flex-start; gap: var(--space-10); padding: var(--space-6); }
|
||||
.p-site-col { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; align-items: flex-start; gap: var(--space-2); }
|
||||
.p-site-label { font: 500 13px/16px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-site-line { font: 400 14px/20px var(--font-sans); color: var(--color-text-secondary); white-space: pre; }
|
||||
.p-site-phone { display: inline-flex; align-items: center; gap: var(--space-2); padding: var(--space-3) var(--space-4); border-radius: var(--radius-full); background: var(--color-bg-inverse); color: var(--color-text-inverse); font: 500 13px/16px var(--font-sans); white-space: nowrap; }
|
||||
.p-site-phone-plain { font: 400 16px/24px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-site-footer { display: flex; justify-content: space-between; padding: var(--space-4) var(--space-6); background: var(--color-bg-sunken); font: 400 12px/16px var(--font-sans); color: var(--color-text-tertiary); }
|
||||
.p-site-footer-link { color: var(--color-text-link); }
|
||||
|
||||
/* ---------- Board ---------- */
|
||||
.p-board { display: flex; flex: none; flex-direction: column; gap: var(--space-3); padding: var(--space-3) var(--space-4); overflow: hidden; background: var(--color-bg-canvas); border-top: 1px solid var(--color-border-subtle); }
|
||||
.p-board-header { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.p-board-title { font: 500 13px/16px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-board-link { font: 500 13px/16px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-lanes { display: flex; align-items: flex-start; gap: var(--space-4); }
|
||||
.p-lane { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; gap: var(--space-2); }
|
||||
.p-lane-header { display: flex; align-items: center; gap: var(--space-2); font: 500 12px/16px var(--font-sans); color: var(--color-text-secondary); }
|
||||
.p-boardcard { display: flex; flex-direction: column; align-items: stretch; gap: var(--space-2); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); }
|
||||
.p-boardcard[data-status="needs-you"] { border: 1.5px solid var(--color-accent-default); padding: calc(var(--space-3) - 0.5px); }
|
||||
.p-boardcard-title { font: 500 14px/20px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-boardcard[data-status="done"] .p-boardcard-title { color: var(--color-text-secondary); }
|
||||
.p-boardcard-meta { font: 400 12px/16px var(--font-sans); color: var(--color-text-tertiary); }
|
||||
.p-boardcard-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px; }
|
||||
.p-boardcard-ask { font: 400 12px/16px var(--font-sans); color: var(--color-accent-text); }
|
||||
.p-progress { height: 3px; overflow: hidden; border-radius: var(--radius-full); background: var(--color-bg-sunken); }
|
||||
.p-progress-fill { display: block; height: 3px; border-radius: var(--radius-full); background: var(--color-accent-default); }
|
||||
|
||||
/* ---------- Compare and approval ---------- */
|
||||
.p-compare { position: relative; width: 100%; height: 100%; overflow: hidden; border-radius: var(--radius-lg); background: var(--color-bg-surface); }
|
||||
.p-compare-layer { position: absolute; inset: 0; overflow: hidden; }
|
||||
.p-compare-divider { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: var(--color-bg-inverse); }
|
||||
.p-compare-handle { position: absolute; top: 50%; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; transform: translate(-50%, -50%); border-radius: var(--radius-full); background: var(--color-bg-inverse); color: var(--color-text-inverse); touch-action: none; cursor: ew-resize; }
|
||||
.p-compare-label { position: absolute; top: 62px; padding: var(--space-1) var(--space-2); border-radius: var(--radius-xs); border: 1px solid var(--color-border-default); font: 500 12px/16px var(--font-sans); }
|
||||
.p-compare-label-before { left: 12px; background: var(--color-bg-surface); color: var(--color-text-primary); }
|
||||
.p-compare-label-after { right: 12px; background: var(--color-bg-inverse); color: var(--color-text-inverse); }
|
||||
.p-approvalcard { display: flex; flex-direction: column; gap: var(--space-4); width: 480px; padding: var(--space-4); overflow: hidden; border-radius: 20px; background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); box-shadow: var(--shadow-modal); font-family: var(--font-sans); }
|
||||
.p-approvalcard-compare { width: 760px; height: 379px; }
|
||||
.p-approval { display: flex; align-items: center; gap: var(--space-4); padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-bg-surface); border: 1px solid var(--color-border-default); }
|
||||
.p-approval-icon { display: inline-flex; flex: none; color: var(--color-status-success-fg); }
|
||||
.p-approval-text { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; }
|
||||
.p-approval-title { font: 500 14px/20px var(--font-sans); color: var(--color-text-primary); }
|
||||
.p-approval-meta { font: 400 12px/16px var(--font-sans); color: var(--color-text-tertiary); }
|
||||
@@ -13,8 +13,9 @@
|
||||
--color-bg-inverse: #14120b;
|
||||
|
||||
--color-text-primary: #14120b;
|
||||
--color-text-secondary: #6b675e;
|
||||
--color-text-tertiary: #8f8b80;
|
||||
/* AA: Figma's #6b675e / #8f8b80 secondary and tertiary text are 4.9:1 and 3.0:1 on sunken. Darkened to 6.0:1 and 4.6:1. */
|
||||
--color-text-secondary: #5e5a52;
|
||||
--color-text-tertiary: #706c64;
|
||||
--color-text-inverse: #f2f1ec;
|
||||
--color-text-on-accent: #ffffff;
|
||||
--color-text-link: #2a52b0;
|
||||
@@ -24,6 +25,8 @@
|
||||
--color-border-strong: #c4c2b8;
|
||||
|
||||
--color-accent-default: #e8590c;
|
||||
/* Fill for chips and counters that carry text. White on #e8590c is 3.6:1; on this it is 4.9:1. */
|
||||
--color-accent-fill: #c24a08;
|
||||
--color-accent-text: #b8420a;
|
||||
--color-accent-subtle: #fdede3;
|
||||
|
||||
@@ -80,8 +83,8 @@
|
||||
--color-bg-inverse: #f2f1ec;
|
||||
|
||||
--color-text-primary: #f2f1ec;
|
||||
--color-text-secondary: #8f8b80;
|
||||
--color-text-tertiary: #6b675e;
|
||||
--color-text-secondary: #aaa59a;
|
||||
--color-text-tertiary: #918d83;
|
||||
--color-text-inverse: #14120b;
|
||||
--color-text-on-accent: #14120b;
|
||||
/* Figma keeps the light value (#2a52b0) in dark, which fails AA on #0e0e0c. Lightened to the dark "working" blue. */
|
||||
@@ -92,6 +95,7 @@
|
||||
--color-border-strong: #4a473f;
|
||||
|
||||
--color-accent-default: #ff7a33;
|
||||
--color-accent-fill: #ff7a33;
|
||||
--color-accent-text: #ff9a5e;
|
||||
--color-accent-subtle: #3a1a08;
|
||||
|
||||
@@ -121,8 +125,8 @@
|
||||
--color-bg-inverse: #f2f1ec;
|
||||
|
||||
--color-text-primary: #f2f1ec;
|
||||
--color-text-secondary: #8f8b80;
|
||||
--color-text-tertiary: #6b675e;
|
||||
--color-text-secondary: #aaa59a;
|
||||
--color-text-tertiary: #918d83;
|
||||
--color-text-inverse: #14120b;
|
||||
--color-text-on-accent: #14120b;
|
||||
--color-text-link: #7c9cf0;
|
||||
@@ -132,6 +136,7 @@
|
||||
--color-border-strong: #4a473f;
|
||||
|
||||
--color-accent-default: #ff7a33;
|
||||
--color-accent-fill: #ff7a33;
|
||||
--color-accent-text: #ff9a5e;
|
||||
--color-accent-subtle: #3a1a08;
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
/** The width the content is designed at, in px. */
|
||||
designWidth: number;
|
||||
/** The height the content is designed at, in px. */
|
||||
designHeight: number;
|
||||
/** Scale used before the container is measured (server render). */
|
||||
initialScale?: number;
|
||||
/** Never scale above this. Default 1. */
|
||||
maxScale?: number;
|
||||
/** Never scale below this; the parent clips what does not fit. Default 0 (always fits). */
|
||||
minScale?: number;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** Scales content designed at a fixed size to the width of its container, and reserves the scaled height. */
|
||||
export function Fit({ designWidth, designHeight, initialScale = 1, maxScale = 1, minScale = 0, className, children }: Props) {
|
||||
const outer = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(initialScale);
|
||||
|
||||
useEffect(() => {
|
||||
const el = outer.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
const update = () => {
|
||||
const w = el.clientWidth;
|
||||
if (w > 0) setScale(Math.max(minScale, Math.min(maxScale, w / designWidth)));
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [designWidth, maxScale, minScale]);
|
||||
|
||||
const outerStyle: CSSProperties = { width: '100%', height: designHeight * scale, position: 'relative', overflow: 'hidden' };
|
||||
const innerStyle: CSSProperties = {
|
||||
width: designWidth,
|
||||
height: designHeight,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
return (
|
||||
<div ref={outer} className={className} style={outerStyle}>
|
||||
<div style={innerStyle}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export type IconName =
|
||||
| 'activity' | 'arrow-right' | 'bell' | 'board' | 'check' | 'check-circle' | 'chevron-down' | 'chevron-right' | 'chevron-up' | 'clock' | 'close' | 'dots'
|
||||
| 'eye' | 'globe' | 'home' | 'list' | 'loader' | 'lock' | 'menu' | 'mic' | 'monitor' | 'paperclip'
|
||||
| 'pencil' | 'plus' | 'pointer' | 'refresh' | 'search' | 'shield-check' | 'sidebar' | 'sliders'
|
||||
| 'shield' | 'smartphone' | 'sparkles' | 'split' | 'stop' | 'undo';
|
||||
|
||||
type Props = { name: IconName; size?: number; className?: string };
|
||||
|
||||
/**
|
||||
* Draws a single-colour Figma icon (public/icons) with a CSS mask, so its colour is the
|
||||
* text colour of the surrounding element and follows the theme. The SVG is the exact Figma export.
|
||||
*/
|
||||
export function Icon({ name, size = 16, className }: Props) {
|
||||
const style = { '--icon-src': `url(/icons/${name}.svg)`, width: size, height: size } as CSSProperties;
|
||||
return <span aria-hidden="true" className={['icon', className].filter(Boolean).join(' ')} style={style} />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Shows content designed at one width at a smaller (or larger) fixed ratio, laid out at the scaled size.
|
||||
* Use for a fixed ratio; use Fit when the ratio follows the container width.
|
||||
*/
|
||||
export function Zoom({ ratio, children, className }: { ratio: number; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={className} style={{ zoom: ratio } as CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user