Compare commits

..

150 Commits

Author SHA1 Message Date
comfydesigner
f5a03b0ae0 feat: freeze auto-reload while subscription is paused
A paused (suspended) subscription can't spend, so auto-reload can't
fire — dim and disable the section the same way the lapsed state does,
rather than showing a misleading enabled/healthy tile. Extends the
frozen prop to cover paused alongside inactive.
2026-07-09 13:22:36 -07:00
comfydesigner
b1fc0e5c76 feat: hide invoice history table, defer to Stripe
Per-invoice download isn't feasible from our data, and Stripe already
owns the full invoice list + downloads, so the Invoices tab now just
shows the next charge and a Full invoice history link out. Drops the
local table, pagination, and its search box (kept useWorkspaceInvoices
for the next-invoice amount and easy restore).
2026-07-09 12:08:48 -07:00
comfydesigner
ea660e71ad fix: stack dense Credits-tab rows at a wider breakpoint
The credits/snapshot grid, plan header, and auto-reload only sit side
by side comfortably on the wide full-settings modal, not the narrower
standalone billing dialog, so raise their stack breakpoint from @2xl
(672px) to @4xl (896px). Lighter chrome (tabs, banners, footers) stays
at @2xl. Also keep the plan credit figure from breaking mid-value.
2026-07-09 01:34:12 -07:00
comfydesigner
45655af67b feat: responsive settings panels via container queries
Below a ~672px panel width (container @2xl), row layouts stack: the
tab bar drops its search to full width, the Credits plan header +
credits/snapshot grid + auto-reload go single-column, and the Activity
footer, invoice banner, and status banners stack their actions. Data
tables now nowrap all cells and scroll horizontally as a unit instead
of wrapping body text. Uses container queries (not viewport) so the
layout tracks the modal's actual width.
2026-07-09 01:27:20 -07:00
comfydesigner
c2d3d1dcb4 fix: stop table header labels wrapping to two lines
Header cells now nowrap, so a tight column lets the table scroll
horizontally (via the existing overflow-auto wrapper) instead of
wrapping the label. TableHead is used only by the workspace billing
tables, so this is scoped to those charts.
2026-07-09 01:05:47 -07:00
comfydesigner
0f15fa1972 feat: disable auto-reload in inactive subscription state
A lapsed plan can't auto-reload, so the section now reads Disabled
(toggle forced off) rather than just dimmed. AutoReloadSection gains a
frozen prop that owns the dim, blocks interaction, and forces the
off state, replacing the parent wrapper's dim so the tile no longer
double-dims.
2026-07-09 00:53:20 -07:00
comfydesigner
52e5f50b05 feat: redesign inactive team-subscription state
Per updated Figma (4453-24226): drop the $0/mo price line and the
'everything in Pro' value-prop panel. The credits tile now shows its
full breakdown frozen/dimmed via a new CreditsTile 'frozen' prop
(mirrors the paused treatment), and auto-reload stays visible but
dimmed instead of hidden. Header uses Manage payment + Reactivate.
2026-07-09 00:46:04 -07:00
comfydesigner
ab29ea72ed feat: plan-ending billing banner
A cancelled-but-still-active team plan now surfaces a calm, owner-only
notice: muted circle icon, 'Your team plan ends on {date}', and a
low-key secondary Reactivate plan action wired to useResubscribe. Sits
last in banner priority since it's an intentional, non-blocking state,
not a problem.
2026-07-08 21:39:32 -07:00
comfydesigner
b00c9f2bb9 fix: amber triangle for paused billing banner
A paused subscription is the escalation of a failed payment (credits
frozen, 'update payment to resume'), so it's an action-needed problem
like out-of-credits and payment-failed, not an informational notice.
All current banners now share the triangle-alert; the circle/muted
notice styling returns with the plan-ending banner.
2026-07-08 21:33:06 -07:00
comfydesigner
5b32c865ac feat: drop seat count from invite-limit tooltip
Matches the count-free member-limit dialog so no UI names a specific
seat number for enterprise or a changed cap.
2026-07-08 21:28:33 -07:00
comfydesigner
5bec5a06a8 fix: consistent severity icons on billing banners
Triangle-alert (amber) now marks every action-needed problem, so
payment-failed matches out-of-credits instead of showing the circle;
circle-alert (muted) is reserved for informational status notices like
paused. Collapses the icon and color into one severity condition.
2026-07-08 21:27:03 -07:00
comfydesigner
67d49de539 feat: drop hard seat count from member-limit dialog copy
The member-limit dialog no longer names a specific seat number, so it
needs no change for enterprise or if the cap moves, and it now notes
that pending invites count toward the limit (rescind an invite).
showMemberLimitDialog drops its now-unused maxSeats argument.
2026-07-08 21:17:54 -07:00
comfydesigner
141f2d933f feat: inactive team subscription state on Credits tab
Lapsed team/enterprise plans now show a reactivation header (title, 0
USD/mo, subtitle, Manage billing + Reactivate plan) and a plan
value-proposition panel in place of the live plan header and member
snapshot. Auto-reload is hidden while inactive. Reactivate wires to the
existing useResubscribe flow.
2026-07-08 21:15:30 -07:00
comfydesigner
c69d36a130 revert: drop the usage bar from the account popover
Removes the monthly-usage progress bar under the credit balance in the user
popover — not needed. The shared usage math stays in useSubscriptionCredits
(still consumed by the credits tile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:32:22 -07:00
comfydesigner
2d6aea2d8f fix: stop auto-page-size from overflowing by a partial row
Measure row/header heights with getBoundingClientRect (fractional) instead
of offsetHeight (integer). Truncated integer heights made the fit calc place
one row too many, overflowing the scroll container by a few pixels and
showing a scrollbar. Fractional heights keep a not-quite-fitting row out,
leaving at most ~1 row of empty space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:24:26 -07:00
comfydesigner
fc3bb1311a feat: show the credits usage bar in the user popover
Adds the monthly-usage progress bar under the credit balance in the account
popover (paid, active plans only), matching the design. Centralizes the
allowance-total and usage math in useSubscriptionCredits so the popover and
the credits tile share one source instead of duplicating it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:19:31 -07:00
comfydesigner
c5b92e1419 fix: keep the credits footer floated to the bottom
Restore mt-auto so the footer floats to the panel's bottom edge; the earlier
pb-10 -> pb-6 change is what aligns it with the other tabs' footers, not
dropping the float.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:11:28 -07:00
comfydesigner
36787eb6c3 style: rebalance activity table user/event-type widths
Pin the User column to a fixed width and let Event type flex to absorb the
table's slack, instead of User (with its short names) soaking up all the
empty space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:09:57 -07:00
comfydesigner
4e45d69686 style: drop last-row divider and tighten credits footer gap
- Remove the trailing divider on the last table row across all five tables
  so it doesn't double up against the container edge.
- Drop mt-auto from the credits-tab footer so it follows the content with the
  normal gap instead of floating to the panel's bottom edge, and match the
  bottom padding to the other tabs (pb-6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:06:24 -07:00
comfydesigner
ae4075b46c style: soften dividers to /20 and drop hover on read-only tables
Row dividers go from /20... down from /30 to /20. Also removes the row hover
highlight on the read-only tables (Activity, Invoices, Members, overview
snapshot) since those rows aren't interactive; the Allowlist keeps its hover
because its rows are selectable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:00:50 -07:00
comfydesigner
c1fc014d34 style: soften table row dividers
Drop the row divider opacity from /60 to /30 so the lines read as subtle
separators; the header/label divider stays at /60.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:54:15 -07:00
comfydesigner
d902343c91 refactor: use divider rows across the workspace tables
Replaces the alternating zebra backgrounds (and the plain / hover-pill
variants) with shadcn-style divider lines plus a subtle hover highlight, so
Activity, Invoices, Members, Allowlist, and the overview snapshot all share
one consistent row treatment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:51:55 -07:00
comfydesigner
b66ad926ab fix: left-align activity hover cards to their trigger
Anchor the user and partner-node hover cards to the start of their trigger
(align="start") so the popover's left edge lines up with the row content,
instead of the default centered placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:33:10 -07:00
comfydesigner
04d371c581 feat: emphasize add-credits when the tile is fully out of credits
Restores the inverted (emphasized) add-credits button when monthly credits
are depleted and no additional credits remain; spending-additional keeps the
quieter tertiary button, and paused stays tertiary + disabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:28:37 -07:00
comfydesigner
72800913e1 chore: rename billing-mock past_due state to at_risk
"past_due" is Stripe jargon that reads oddly next to the other subscription
statuses; "at_risk" better conveys "payment failing, about to pause."

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:23:02 -07:00
comfydesigner
d558296f3d feat: dim the credits tile while the subscription is paused
Paused subscriptions can't spend credits, so fade the whole tile to read as
frozen and defer to the Update-payment banner — matching how the auto-reload
tile dims when paused. Only applies to the paused state, not the at-risk
grace period.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:22:41 -07:00
comfydesigner
e12dea7af1 feat: add empty state to the recent activity snapshot tab
The snapshot tile now shows a per-tab empty state: the existing
"No credits used yet this month" for Top spenders, and a new
"No activity yet" for Recent activity when there are no events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:16:20 -07:00
comfydesigner
698c74b458 refactor: use lucide-coins for the credits icon and refresh banner copy
Swaps every credits icon from lucide-component to lucide-coins across the
credit displays (top-up dialogs, user popovers, pricing table, credit badge,
node search, partner node cost). Also updates the out-of-credits banner copy
to the softer "add more credits to continue generating or wait until credits
refill" wording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:52:06 -07:00
comfydesigner
1f48c6979e fix: remove duplicate out-of-credits banner on the members tab
The members tab had its own MembersOutOfCreditsBanner, which now duplicates
the out-of-credits variant added to the shared BillingStatusBanner. Drop the
members-specific banner, its useMembersPanel logic, the orphaned component,
its i18n keys, and the tests that covered it — the shared banner now handles
this state consistently across all tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:49:16 -07:00
comfydesigner
a52cb209fe fix: add bottom padding to members and allowlist tabs
These panels run their own layout (not through PlanCreditsPanelContent), so
they missed the tab bottom padding and sat flush to the dialog edge. Add
pb-6 to match the Activity/Invoices tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:37:48 -07:00
comfydesigner
f2f3e3652d feat: move out-of-credits messaging to the status banner
Adds an out-of-credits variant to the shared billing status banner (owner/
admin only, dismissible), slotting into the same top banner in priority
order paused → payment-failed → out of credits. In exchange the credits
tile drops its punch-out empty-state notices, so its depleted/out-of-credits
states read lean like the paused state (no inset toast, tertiary add-credits
button). Prunes the now-unused subscription notice i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:24:53 -07:00
comfydesigner
eac08385a1 fix: lower table row floor to 1 and trim tab bottom padding
- Activity/Invoices tables fit rows down to 1 instead of forcing a floor of
  5, so short screens paginate instead of overflowing with a scrollbar.
- Reduce the Activity/Invoices bottom padding from 40px to 24px to match the
  top and reclaim vertical space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:33:33 -07:00
comfydesigner
562886d1dc feat: add enterprise tier to the billing mock and plan name
Adds an "enterprise" option to the billing mock harness tier picker
(subscription_tier ENTERPRISE) and relabels the team plan as "Enterprise"
wherever the team plan name shows — the Credits-tab plan header and the
cloud subscription panel. Layout, credits, and seats are unchanged; the
team credit stop still drives the credits bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:31:15 -07:00
comfydesigner
c35c6f2871 feat: simplify paused credits tab per design
- Keep the percentage on the allowance row when paused (e.g. "0% used")
  instead of swapping in "Refill paused"; drop the now-unused key.
- Replace the plan header's "Renews on {date}" with "Paused" when the
  subscription is paused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:20:37 -07:00
comfydesigner
c1f393de2e feat: surface full invoice history in the paused billing banner
Instead of dropping the "Full invoice history" action when the next-invoice
banner is hidden on pause, add it as a ghost button beside "Update payment"
in the billing status banner (via a new actions slot), shown only on the
paused Invoices tab. Reverts the footer fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:16:35 -07:00
comfydesigner
678f9e06e4 feat: rename first tab to Credits and gate next-invoice on paused
- Rename the Plan & Credits first tab "Overview" -> "Credits".
- Drop the "Next month invoice" tile from that tab; it now lives only on
  the Invoices tab, removing the duplication.
- On the Invoices tab, hide the next-invoice banner when the subscription
  is paused (the parent "Subscription paused" banner covers that state) and
  fall back to a footer "Full invoice history" link so it stays reachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:11:28 -07:00
comfydesigner
57aca543b2 feat: add next-month-invoice banner to the invoices tab
Mirrors the overview banner at the top of the Invoices tab per design:
muted "Next month invoice" label, 24px semibold amount with a 16px USD
suffix, and the "Full invoice history" action moved from the footer into
the banner as a secondary button. Footer keeps only pagination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:48:04 -07:00
comfydesigner
7b808f2674 fix: give the paused auto-reload badge the inverted treatment
Paused is an alert state; use the high-contrast (inverted) badge so it
stands out, keeping the quieter secondary badge for the "off" state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:43:40 -07:00
comfydesigner
92c0f8f4c9 fix: match auto-reload amount size to the credits tile total
The reload credits number now uses the same 24px treatment as the total
credits number, per design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:42:09 -07:00
comfydesigner
878b540b40 feat: track full-year allowance on the credits bar for annual plans
Annual subscribers receive the whole year's credits upfront, so the bar's
total is now the monthly nominal times the cycle length (x12 for annual) and
the "% used" math follows. Renames the monthly-prefixed internals to
allowance/cycle and makes the progress and "used after" copy cycle-neutral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:41:03 -07:00
comfydesigner
b9ad439544 feat: update auto-reload budget tile to match design
Budget section now shows a color-coded "{percent}% spent" on the right of
the Monthly budget row (muted → amber near full → red when paused/full) and
a single "{spent} of {budget}" sub-label under the bar, with a divider above
it. Drops the left spent label and the reloads-left line, and prunes the
now-unused tile i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:34:34 -07:00
comfydesigner
c26d7e0063 feat: fit snapshot rows to the credits tile height
Both overview tiles stretch to equal height via the grid; the credits tile
sets that height and the top-spenders tile now fits as many user rows as the
space allows (measured with useAutoPageSize), keeping "See more" pinned to
the bottom. Raises the snapshot pool so a taller tile can show more rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:29:35 -07:00
comfydesigner
4bfce5fac3 fix: mute allowance label and vary it by billing duration
The credits-bar cycle label is now muted like the surrounding labels and
reads "Yearly" on annual subscriptions, "Monthly" otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:24:14 -07:00
comfydesigner
10f8271b8e fix: swap credit tile font sizes to match design
Plan header monthly grant is the 16px line; the Total credits balance is
the prominent 24px number. Had these inverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:22:07 -07:00
comfydesigner
0c2fa9afc4 feat: simplify credits + auto-reload tiles per updated design
- Plan header shows monthly credit grant (coins) instead of $/mo price;
  next-month invoice already covers the dollar amount.
- CreditsTile: total balance uses 16px text; monthly row shows a single
  "{percent}% used" status (or "Refill paused") in place of the
  "used / left of total" breakdown and per-row refills date.
- AutoReloadSection: reload amount uses 16px text; drop the recent-reload
  history block and its now-unused helpers.
- Prune orphaned subscription i18n keys (refillsDate, refillsNextCycle,
  creditsUsed, creditsLeftOfTotal); add percentUsed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:13:53 -07:00
comfydesigner
33233a3aed chore(workspace): rename Activity inflow event to "Additional credits added"
Was "Credit top-up".
2026-07-08 13:55:25 -07:00
comfydesigner
a9eee9e14c feat(workspace): make Activity a credit ledger (inflows + usage)
Add auto-reload / top-up inflow events to the activity feed: they render '—' in
the User column (workspace-level, not a person), '—' for details, and a gold
'+N' credit amount to distinguish adds from usage. Rename the column "Credits
used" -> "Credits" since it now shows both directions. Inflows are excluded from
per-user rollups and stay workspace-level even in a member's scoped view.
2026-07-08 13:39:49 -07:00
comfydesigner
44d698678a test: align CreditsTile + MembersPanelContent tests with billing changes
MembersPanelContent now reads useBillingContext (isPaused) and renders
BillingStatusBanner — mock the context and stub the banner. CreditsTile's mock
gains isPaused, and its stale add-credits variant expectation is updated to
'tertiary' to match the component.
2026-07-08 13:16:24 -07:00
comfydesigner
cb7ef058a4 chore(workspace): expose aria-sort on sortable table headers
Add aria-sort (ascending/descending/none) to the Activity, Invoices, Members,
and Partner-nodes column headers so screen readers announce the active sort
column and direction. Adds a small ariaSort() helper alongside each sortIcon().
2026-07-08 13:08:39 -07:00
comfydesigner
fae8e049df chore(workspace): associate auto-reload dialog labels with their inputs
Add for/id on the threshold and reload fields and aria-labelledby on the budget
field so screen readers announce each input's purpose.
2026-07-08 12:42:01 -07:00
comfydesigner
53ad3d681d chore: address CodeRabbit nits — locale-aware formatting, a11y, api timeout
- Route currency/date through vue-i18n n()/d() instead of hardcoded en-US
  (invoices, overview, auto-reload dialog + section, overview renewal date)
- a11y: scope="col" on table headers, real alt on workspace avatar, role=status
  on the billing banner
- partnerNodesApi: 10s request timeout so a hung call surfaces the error toast
- overview: lucide ellipsis instead of PrimeIcons, locale-aware Learn more link
2026-07-08 12:40:16 -07:00
comfydesigner
c90babcf73 fix: address CodeRabbit review — correctness fixes + isPaused dedup
- AutoReloadDialogContent: ?? instead of || for numeric defaults (0 is valid)
- useAutoReload: budgetUsedFraction null-check consistent with hasBudget (0 budget)
- SettingDialog: flush content-padding for all workspace panels, not just Plan&Credits
- WorkspaceMembersPanelContent: void the onMounted fetches (no floating promises)
- usePartnerNodes: restore original last_modified on failed enable/bulk rollback
- lift isPaused into useBillingContext; drop the duplicated derivations in
  CreditsTile / MembersPanelContent / BillingStatusBanner
2026-07-08 11:55:16 -07:00
comfydesigner
ce73c2480f feat(workspace): rename Partner Nodes tab to Allowlist with shield-check icon
The section will host both partner-node and model allowlists (as sub-tabs) later,
so rename the sidebar entry to the umbrella "Allowlist" and swap the node-specific
icon for shield-check. Internal keys/routes unchanged.
2026-07-07 19:39:10 -07:00
comfydesigner
3cb63e3209 feat(workspace): hover card showing the partner node used on activity rows
Add a HoverCard on the 'Partner node usage' event-type cell in the Activity tab
that reveals which partner node was used ("Partner node used -> Nano Banana
Pro"), mirroring the existing per-user hover card. Mock the node per event.
2026-07-07 19:14:36 -07:00
comfydesigner
7317b73a38 style(workspace): pull invoices Event type next to Date
Fix the Date column width (w-40, matching Activity) so Event type no longer
splits the leftover space with it and sits right beside Date, per the Figma.
2026-07-07 19:10:47 -07:00
comfydesigner
beb0a5e12b fix(workspace): overlay partner-nodes selection bar to stop panel reflow
The SelectionBar's in-flow wrapper was a child of the gap-4 flex column, so
toggling it in added a gap and nudged the bottom rows. Anchor the panel and
overlay the bar (absolute) so it floats without participating in layout.
2026-07-07 19:08:44 -07:00
comfydesigner
0124ea8d43 refactor: extract reusable SelectionBar, use it for partner nodes
The floating bulk-selection bar built for Media Assets (PR #13043) is the same
shell the partner-nodes tab needs — only the action differs. Extract the shell
(inverted floating pill: deselect, count, right-aligned actions slot) into a
shared SelectionBar; MediaAssetSelectionBar now composes it (download/delete in
the slot), and the partner-nodes tab uses it with a bulk enable/disable Switch.
2026-07-07 18:29:33 -07:00
comfydesigner
0be144d1d6 feat(subscription): distinct paused state for the credit tile
When the subscription is paused (failed payment), the zeroed balance was falling
into the generic out-of-credits UI. Give paused its own state: drop the
out-of-credits notice, show "Refill paused" + an empty bar (0 used), and disable
Add credits — since topping up doesn't help until payment is resolved. Out of
credits stays independent of the paused/payment-failure state.
2026-07-07 17:43:24 -07:00
comfydesigner
77330e8f60 feat(workspace): always show partner-nodes select-all checkbox
Keep the header select-all visible instead of only after a selection exists, so
bulk-selection is discoverable up front (Gmail/Linear pattern). Row checkboxes
stay hover-revealed.
2026-07-07 17:34:11 -07:00
comfydesigner
8fb73ff290 fix(workspace): place billing banner below panel headers, dedupe when paused
Move the billing banner below the header/tab row in Members and Partner Nodes so
it sits in the same spot as Plan & Credits (under the tabs) instead of above.
When the subscription is paused the balance is zero, which also tripped the
Members out-of-credits banner — suppress it while paused so only the paused
banner shows.
2026-07-07 17:32:50 -07:00
comfydesigner
674143721f fix(workspace): dark billing banner with inverted button
Keep the banner on the dark surface; use the inverted (white) button variant so
the Update payment action is what catches the eye, per the Figma.
2026-07-07 17:28:11 -07:00
comfydesigner
52c7944f2c style(workspace): invert billing banner, clarify harness field labels
Use the inverted (light-on-dark) treatment for the payment banner so it draws
the eye against the dark panel. Rename harness picker labels 'state' -> 'subscription
state' and 'balance' -> 'credit balance' (labels are display-only, decoupled from
the config keys).
2026-07-07 17:13:12 -07:00
comfydesigner
6dcc34b595 feat(workspace): payment-failed / paused billing banner across workspace tabs
Add a BillingStatusBanner that surfaces a payment failure on every workspace
settings tab (Plan & Credits sub-tabs, Members, Partner Nodes), per the Figma
annotation. Two states: payment declined (grace-period warning, owner/admin only
since members can't act) and subscription paused (owners/admins get an Update
payment action; members get an informational variant pointing to admins). Reads
the existing billing_status / subscription_status the context already exposes.

Add 'paused' to BillingSubscriptionStatus and two harness states (past_due,
paused) to drive it; paused also zeroes the balance.
2026-07-07 16:57:54 -07:00
comfydesigner
bef5616aba fix(workspace): clamp billing-mock panel into viewport on load
The panel persists its dragged left/top, but on load it applied them raw, so a
position saved on a larger display landed off-screen on a smaller one with no
header to grab. Re-clamp the persisted position to the current viewport.
2026-07-07 16:38:29 -07:00
comfydesigner
7b7cd0342f style(workspace): widen overview footer link gap to 16px
gap-2 -> gap-4 per updated spacing.
2026-07-07 16:36:22 -07:00
comfydesigner
8d8d0ed61d fix(workspace): reserve partner-nodes scrollbar gutter, dedupe node names
Swap the hidden scrollbar for a reserved gutter (scrollbar-gutter: stable) so the
list keeps a visible scrollbar without the rows shifting when it appears; mirror
the gutter on the auto-enable row so its toggle stays aligned with the column.

Dedupe partner nodes by display name (5 collisions, e.g. two "OpenAI GPT Image
2") so the governance table has no indistinguishable duplicate rows: 213 -> 208.
2026-07-07 16:24:35 -07:00
comfydesigner
d7e074bbf5 fix(workspace): float overview footer to bottom, hide partner-nodes scrollbar
The overview footer links now use mt-auto so they pin to the bottom of the panel
when the content is short (a member's near-empty tab) and scroll below the
content when it overflows — a pure-flexbox sticky footer, no height math.

The partner-nodes table is the only settings table that isn't paginated, so its
scrollbar was the lone visible one in the dialog and its width shifted the row
toggles out of line with the auto-enable toggle. Hide it (scrollbar-hide, as the
overview already does) so the columns line up again.
2026-07-07 16:19:33 -07:00
comfydesigner
4daf17c9d7 fix(workspace): list the full partner-node catalog in the governance mock
The canned list only had ~96 nodes — roughly the first page of the API-node
catalog alphabetically — so everything after "Luma" (OpenAI, Runway, Recraft,
Tripo, Veo, Stability AI, Meshy, Rodin, Vidu, Wan, etc.) was missing. Replace it
with the full 213-node set, matching what the node-search "Partner" filter shows,
labelled by the node's partner category.
2026-07-07 16:11:49 -07:00
comfydesigner
ab275ba35e fix(workspace): even partner-node row fill + reactive auto-enable label
The disabled-node opacity was on the whole TableCell, dimming the selection/hover
fill on the name and partner cells while the other cells stayed full — an uneven
row fill. Dim only the cell content so the fill is uniform.

Make the auto-enable footer label reflect the switch (auto-enabled/auto-disabled)
and stack both strings in one grid cell so the width is fixed and the row doesn't
reflow when it toggles.
2026-07-07 15:57:45 -07:00
comfydesigner
f5f365c58a fix(workspace): left-align overview footer links
Match Figma: cluster the footer links to the left with an 8px gap instead of
spreading them across the row.
2026-07-07 15:52:34 -07:00
comfydesigner
64bcf2748e Merge remote-tracking branch 'origin/main' into comfydesigner/team-workspaces-v1
# Conflicts:
#	src/platform/cloud/subscription/components/CreditsTile.vue
2026-07-07 15:15:13 -07:00
comfydesigner
b2400f764d fix(workspace): drop trailing period from members usage label
Members (who can't invite) see just the count, so the trailing period reads as a
stray dangling mark; owners keep it as a separator before "Need more members?".
2026-07-07 15:06:53 -07:00
comfydesigner
4925f0ca0c style(workspace): space out overview footer links
Use justify-between so the footer links spread across the row.
2026-07-07 15:06:51 -07:00
comfydesigner
123bdad84c fix(workspace): drop unused AutoReloadScenario export
knip flags it as an unused exported type (the dev harness keeps its own local
copy of the union), which fails the pre-push hook. Scope it to the module.
2026-07-07 15:06:50 -07:00
comfydesigner
04e79dc2da feat(workspace): role-based visibility for members
- Members can view pending invites (view-only; no resend/revoke) and see just
  the Email/Role columns in the members table
- Hide billing from members: plan price, Manage payment, member snapshot, next
  invoice, the auto-reload section, and the Invoices tab
- Scope the Activity tab to the member's own usage and right-align its
  pagination; hide the per-user footer links
- Gate the members footer "Contact us" / request-more behind invite permission
2026-07-07 15:06:48 -07:00
comfydesigner
9b54efdef4 feat(workspace): raise seat limit to 50 and link request-more to the team-plan form
- MAX_WORKSPACE_MEMBERS 30 -> 50
- Member-limit dialog's "Request more" opens the team-plan request form
2026-07-07 15:06:46 -07:00
comfydesigner
cf3e4c4620 feat(workspace): redesign the auto-reload dialog
- Drop the subtitle; render Monthly budget as a divider band, not a boxed card
- Refine copy and collapse the minimum-amount error to a single string
- Size the Credits/USD toggle to match the Update button
2026-07-07 15:06:44 -07:00
comfydesigner
53c7566665 style(workspace): flush settings panels and tabular credit figures
- Add a 'flush' content-padding variant to BaseModalLayout; the settings
  dialog uses it for workspace panels so content runs to the bottom edge
- Tabular-nums on the CreditsTile figures so columns align
2026-07-07 15:06:43 -07:00
comfydesigner
001b9f3ace feat(workspace): auto-reload credits, table auto-sizing, Overview polish
- Credit auto-reload: section, tile states (healthy/near-limit/paused/off/
  empty), and configure dialog with Credits/USD toggle and $5 minimum
- Auto-size Activity/Invoices tables to the dialog height (useAutoPageSize)
- Activity footer: Full activity link + See Members deep-link
- Overview footer links wired (Learn more, Partner Nodes pricing, support)
- Overview snapshot empty state; future renewal date
- billingmock: balance "full" state, team_credit_stop scaling, auto-reload
  scenario picker (live, no reload) + stale-value sanitizing on load
- Plan & Credits nav icon -> receipt-text
2026-07-07 15:06:41 -07:00
comfydesigner
2324417e22 chore(billingmock): tie member usage to balance state, add partial
Zero every member's credits_used_this_month in the fully-funded state so
both Overview tiles agree that nothing has been spent, and add a 'partial'
balance option (now the default) where members reflect their mock usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:39 -07:00
comfydesigner
9bbe75eeaf feat: snapshot uses Members data; See more deep-links per tab
Derive the Overview member snapshot from the workspace members store (Top
spenders by credits, Recent activity by last activity) so it matches the
Members tab. Wire See more: Top spenders opens the Members panel
pre-sorted by credit usage (via a settings-navigation helper + a one-shot
members sort), Recent activity opens the Activity tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:37 -07:00
comfydesigner
0ac06403d8 style: strip native chrome from the additional-credits info button
Reset the raw info tooltip button's native appearance so it renders as a
plain muted icon (with a cursor-help + hover tint) instead of a filled
button box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:36 -07:00
comfydesigner
786ae99afa style: unify disabled styling for workspace menu items
Drop the redundant per-item text-danger/50 and opacity-50 classes so
disabled Delete and Leave both render with the shared data-disabled muted
style; Delete stays red only when actually enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:34 -07:00
comfydesigner
1e2cfd54c8 style: restore 24x20 tile padding; tertiary Add credits button
Revert both Overview tiles to px-6 py-5 padding and switch the CreditsTile
Add credits button to the tertiary variant so it matches the See more
button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:33 -07:00
comfydesigner
b206ca457f style: match Overview credits tile padding to the snapshot tile
Pass p-4 to the Overview CreditsTile so its padding matches the member
snapshot tile (legacy CreditsTile usages keep px-6 py-5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:31 -07:00
comfydesigner
5dd04a5e3f refactor: mini snapshot table reuses shadcn Table; fix line tabs; tertiary See more
Rebuild the member-snapshot list with the shared Table component (zebra
rows, coins header, divider) so it mirrors the Activity/Invoices tables
in miniature. Reset the native button appearance on TabsTrigger and drop
the list border so the tabs render as clean underline tabs. Use the
tertiary button variant for See more so it stands out on the tile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:29 -07:00
comfydesigner
5e5bf8891a refactor: reuse CreditsTile in Overview; add shadcn line Tabs
Replace the hand-built Overview credits tile with the shared CreditsTile
(real billing data), swapping its credit icon to lucide--coins and
letting callers pass a class so the Overview can drop the border. Add a
shadcn-style line Tabs component and use it for the member-snapshot
Top spenders / Recent activity toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:27 -07:00
comfydesigner
e75f32b56f chore(billingmock): add admin role, map owner to current user, keep dialog open
Add an 'admin' role option (owner-role, non-creator) to the harness. For
'owner', assign the signed-in email to the creator member so the
original-owner gating (Change plan / Cancel plan) resolves true; 'admin'
keeps a separate creator and injects the current user as a non-original
owner. Stop pointerdown/focusin from bubbling out of the panel so
interacting with the picker no longer dismisses the settings dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:26 -07:00
comfydesigner
46a357113b feat: owner-only Cancel plan menu on the Overview plan header
Add a Cancel plan overflow menu (owner + active, non-cancelled, non-free
subscription) to the Overview plan header, matching the old design. Hide
the overflow menu and Change plan for non-owners (Admins/Members).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:24 -07:00
comfydesigner
d5ebe903bd style: use modal panel background for Overview tiles, drop borders
Match the credits and member-snapshot tiles to the modal sidebar color
(bg-modal-panel-background) and remove their outline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:22 -07:00
comfydesigner
c3c22d3e3a style: round zebra rows 4px and remove row hover on Activity/Invoices
Paint the alternating stripe on the cells (with 4px rounded end caps)
instead of the table row, so it can render a radius and no longer flickers
on hover. These read-only tables have no per-row interaction, so the
hover state is fully removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:21 -07:00
comfydesigner
93dc76728e style: make Overview footer 32px tall
Match the Overview footer-link row height to the other footers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:19 -07:00
comfydesigner
a0932ba40f fix: hide legacy Credits nav when workspace Plan & Credits is shown
The per-account Credits panel duplicated the new workspace Plan & Credits
entry in the Workspace sidebar group; suppress it when the workspace
panel is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:18 -07:00
comfydesigner
af5e9d0766 feat: build Plan & Credits Overview (plan, credits, member snapshot)
Replace the Overview placeholder with a real layout: plan header
(price/renewal + actions), a credits tile (total/monthly/additional with
progress) beside a member-snapshot tile (Top spenders / Recent activity
+ See more), and a next-invoice card with footer links. Data is
client-side mock via useWorkspaceOverview; See more / Invoices navigate
to those tabs. Adds a small ProgressBar. Auto-reload section follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:16 -07:00
comfydesigner
c3ca88161c feat: tabular date column, subtle zebra rows, 11 rows per page
Use tabular-nums on the Activity/Invoices date column, add a faint
alternating-row tint (matching the Figma) on those read-only tables, and
fit one more row per page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:14 -07:00
comfydesigner
e499bfc172 fix: prevent clipped table rows, tidy pagination and popover
Make the Activity/Invoices table area a graceful scroll region with a
sticky header (so a page can never clip its last row) and drop the page
size to 10 to fit the card. Render pagination with the shared Button
component, and use the semantic border token on the hover card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:12 -07:00
comfydesigner
96ac9f04c0 fix: lift hover card above the settings dialog; add muted user badge colors
Share the hover card's open state so its content can lift past the
settings dialog's incrementing modal z-index (it was rendering behind
the dialog, so nothing appeared on hover).

Give user monogram badges a stable, muted low-saturation color from a
sampled palette across the Members, Pending, and Activity tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:10 -07:00
comfydesigner
57f24854be style: 10px activity badge letters and larger page size
Shrink the Activity user badge letter to 10px (text-2xs), and bump
Activity/Invoices to 12 rows per page (with a longer mock list) now that
rows are ~40px tall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:08 -07:00
comfydesigner
3f9f2a7782 feat: add user hover card to the Activity table
Add a shadcn-style HoverCard (HoverCard/Trigger/Content on reka-ui) and
use it on the Activity table's User cell to surface that user's lifetime
credits used and last activity, aggregated across all events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:07 -07:00
comfydesigner
f5844a0a87 style: shrink activity user badges to 20x20
Use a 20px avatar in the Activity table (Members keeps 32px).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:05 -07:00
comfydesigner
f01448d6b9 fix: use static i18n keys for Plan & Credits tab labels
Resolve tab labels via static t() calls so the unused-key linter can
trace them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:03 -07:00
comfydesigner
3a31a99eaf feat: add Activity and Invoices views to Plan & Credits
Add Overview/Activity/Invoices sub-tabs to the Plan & Credits panel.
Activity and Invoices render shadcn tables (Date/User/Event/Credits and
Date/Event/Price) sourced from client-side mock data, sortable, with a
32px footer that pairs a history link with a reka-based Pagination
component instead of scrolling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:02 -07:00
comfydesigner
053d559d15 style: tighten out-of-credits banner to match Figma
Reset the body paragraph's default margin so the banner loses the extra
vertical whitespace and matches the compact Figma layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:00 -07:00
comfydesigner
8135842cce chore: expand mock roster so the members table scrolls
Grow the team workspace to a full roster (near the seat cap) so the
sticky-header scroll behavior and usage footer can be seen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:58 -07:00
comfydesigner
6ecb13a6ce chore: add more mock members to the team workspace
Expand the billing mock harness team roster to eight members with varied
roles, activity times, and monthly credit usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:57 -07:00
comfydesigner
de95ef0fbd fix: make the Rename menu item start inline editing
Selecting Rename opened the editor, but reka returned focus to the menu
trigger on close, blurring the just-focused input and committing an empty
rename. Suppress that focus restoration for the close that starts a
rename so the input keeps focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:55 -07:00
comfydesigner
f03cd5de33 style: match members footer height and Contact us button sizing
Fix the members footer to 32px tall (matching the partner nodes footer)
and bump Contact us to size md with 14px text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:53 -07:00
comfydesigner
529248fb7e style: show only the email on pending invite rows
Drop the derived display-name line; the email is the sole identifier
for a pending invite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:52 -07:00
comfydesigner
2c9bee1da3 style: letter avatars for members, drop row hover, round partner rows
Use letter-initial avatars in the Members table (matching pending
invites) and drop the unused photo/UserAvatar path. Remove the hover
highlight on member and pending-invite rows. Give partner-node rows a
4px radius by painting the hover/selected background on the cells with
rounded end caps (table rows can't render border-radius directly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:50 -07:00
comfydesigner
ba494b3d4a fix: match sort-header font to page font in workspace tables
Native button headers didn't inherit the page font-family, so sortable
column labels rendered in a different font than the non-sortable Pending
Email span. Force font inheritance on the shared sort-header class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:49 -07:00
comfydesigner
2b49d78637 fix: give invite dialog a fixed width and lighter email badges
The dialog hugged its content width, so it grew while typing. Pin it to
a fixed 33rem so emails wrap to new lines (height grows) instead. Raise
the email badge to tertiary-background-hover so it reads against the
input background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:47 -07:00
comfydesigner
b579234d55 feat: mock pending invites and hide empty Pending count
Serve two mock pending invites (team workspaces) from the billing mock
harness, and show the Pending tab as plain 'Pending' with no count when
there are none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:45 -07:00
comfydesigner
d9a5d28364 style: drop redundant Partner nodes header title
Remove the 'Partner nodes' title so the description moves up and the
table aligns vertically with the Members tab. Center the header row to
match the Members header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:43 -07:00
comfydesigner
5ec65f366e style: use lg size for Members/Pending tab buttons
Match the tab buttons to the Invite button's size and styling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:42 -07:00
comfydesigner
5e9d1b89f9 feat: add member-limit and workflow-queued dialogs
Add a shared 'request more' dialog (title, message, Close + Request more)
and register both the 'workspace at member limit' and 'workflow queued'
variants in the dialog service. The invite button now stays enabled at
the seat cap and opens the member-limit dialog on click; the queued dialog
is previewable from the billing mock harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:40 -07:00
comfydesigner
4308498b79 feat: redesign Members panel as a table with activity and credits
Convert the active-members and pending-invites lists to the shadcn table
(matching Partner Nodes): columns Email, Role, Last activity, Credits
used this month, and a row overflow menu, with sortable headers. Move the
Members/Pending tabs up into the header row beside search and invite, and
add a per-seat usage footer.

Add an out-of-credits banner above the table, driven by the workspace
balance, with Dismiss and Add credits actions. Menus render non-modal so
they don't dismiss the settings dialog. Retire MemberListItem and
PendingInvitesList in favor of MemberTableRow and PendingInviteRow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:38 -07:00
comfydesigner
3bd7681e84 feat: add member last-activity and monthly-credit fields
Extend WorkspaceMember (and the optional API fields) with lastActivity
and creditsUsedThisMonth for the redesigned Members table, populate them
in the billing mock harness, and add an abbreviated relative-time helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:37 -07:00
comfydesigner
1e44a207b9 feat: add Rename to workspace menu and fix dialog dismissal
Add a 'Rename Workspace' item (above a divider) to the header overflow
menu, wired through a shared useWorkspaceRename composable so it and the
header double-click drive the same inline edit. Drop the menu-item icons
to match the Figma.

Fix the settings dialog closing when the menu trigger is clicked while
open: the dialog is non-modal but reka's DropdownMenu defaulted to modal,
which disables outside pointer-events and retargets the trigger click to
a non-whitelisted body element. Thread a modal prop through DropdownMenu
and render this menu non-modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:35 -07:00
comfydesigner
ba99a98807 fix: match rename input font and pluralize char-limit hint
Force the inline rename input to inherit the page font-family so it
renders identically to the resting name heading (native controls fall
back to the UA font otherwise). Reword the character-limit hint to
'N characters left' with proper singular/plural forms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:33 -07:00
comfydesigner
98e42e53b7 feat(workspace): reduce workspace name limit to 30 characters
Extract a shared WORKSPACE_NAME_MAX_LENGTH constant (was 50, now 30) and use it
across the inline rename, create, and edit flows plus the validation copy, so
the limit stays consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:31 -07:00
comfydesigner
baf75706bd style(workspace): default cursor on the workspace name until editing
The resting name used a text cursor, implying it was already editable. Use the
default cursor; the input's native text cursor is the feedback once editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:30 -07:00
comfydesigner
4242be2707 feat(workspace): rename shows a '{n} left' counter near the 50-char limit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:27 -07:00
comfydesigner
5c7ccbf2a4 style(workspace): auto-enable footer to 32px, drop top padding
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:26 -07:00
comfydesigner
21923f4e9a feat(workspace): move delete/leave to a top overflow menu; rename-input polish
Remove the members-tab overflow (...) button — editing is now inline — and
relocate delete/leave to a WorkspaceMenuButton in the dialog header next to the
X (shown on every workspace panel). Drop the now-inline 'Edit workspace details'
item and hide the menu when it has no actions. Rename input: strip native
appearance/border/padding so the text no longer shifts on edit; enforce a
50-char limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:24 -07:00
comfydesigner
6e67964317 feat(workspace): mock workspace image replace via hover pencil (owner/admin)
Add optional imageUrl to WorkspaceProfilePic (renders the image over the
gradient+initial fallback). In the settings header, hovering the avatar reveals
a pencil overlay (owner/admin only); clicking opens a file picker and shows the
chosen image as a local data-URL preview. Client-side only — no upload or
persistence, per the prototype scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:22 -07:00
comfydesigner
11be2af866 feat(workspace): inline rename in the settings header (owner/admin)
Double-click the workspace name to rename it inline (Enter/blur commits, Esc
cancels), gated to owners/admins with a 'Double-click to rename' tooltip; uses
the existing updateWorkspaceName. Make the mock harness route handlers
body-aware and add a PATCH /api/workspaces/:id route so the rename reflects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:21 -07:00
comfydesigner
2c92b2cc6c style(workspace): members-header overflow button uses secondary-background
Drop the lighter selected-surface override so the members-header ... button
matches the search, invite, and close buttons (all secondary-background).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:19 -07:00
comfydesigner
ffb925837a style(ui): bump lg SearchInput text to 14px
The large SearchInput variant (settings content search, asset browser, sample
model selector) now uses text-sm (14px) instead of text-xs; the container
already has room. Other sizes unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:17 -07:00
comfydesigner
af2f09304d feat(workspace): drop partner filter for V1, tabular-nums on selection count
Search already matches partner names, so the separate partner filter is
redundant for V1 — remove it (and the now-unused composable state, i18n, dead
imports); revisit if requested. Use tabular-nums on the bulk-selection count so
the toolbar width barely moves as the number changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:16 -07:00
comfydesigner
b01c01625a fix(workspace): stop breaking full-color provider icons; taller header; unbold titles
The comfy icon set keeps multi-color brand icons and only maps monotone glyphs to
currentColor. Tinting the full-color ones (ByteDance/Kling/Luma/Gemini/Tencent)
replaced their artwork with a gradient square; only tint the monotone (solid)
providers now. Raise the Partner Nodes column header to 56px and unbold the
Partner Nodes / Members section titles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:14 -07:00
comfydesigner
fef7ad95f6 fix(dialog): clicking a menu trigger no longer dismisses the parent dialog
Reka treats the body-portaled dropdown content as outside the dialog, and the
outside-pointer handler only whitelisted overlay/menu content — not the trigger.
So reopening/closing a DropdownMenu (e.g. the Partner Nodes filter) via its
trigger dismissed the whole Settings dialog. Whitelist aria-haspopup triggers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:12 -07:00
comfydesigner
77624ad2b6 fix(ui): table scroll wrapper takes layout class; align auto-enable toggle
The Table component applied its class to the inner <table> instead of the scroll
wrapper, so the Partner Nodes scroll container was unconstrained and the sticky
header gap/height shifted on scroll. Apply the class to the wrapper and set
border-spacing:0 on the table so the sticky header sits flush. Pad the
auto-enable footer so its toggle lines up with the in-table row toggles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:11 -07:00
comfydesigner
2872fd4062 style(workspace): muted partner node names, disabled dim, brand-colored badges
Match the Figma partner nodes table: node names use muted-foreground; disabled
rows drop the name and partner to 30% opacity (date stays readable, toggle shows
its off state) instead of a flat row dim. Tint the monochrome provider icons
with their brand color (Anthropic coral #D97757, gradients like Kling painted
over the icon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:09 -07:00
comfydesigner
6948ff3fc9 fix(workspace): partner nodes sticky header + divider
The Table component wraps its table in its own overflow-auto div, so nesting it
in a second scroll container made the sticky <thead> stick to the inner
non-scrolling div and scroll away. Use the Table as the single scroll container
and put the header divider on the <th> cells so it stays as the sticky header's
lower border. Move the empty state into a spanning row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:08 -07:00
comfydesigner
3e867da225 style(workspace): 16px table padding, circular partner badge with background
Reduce the Partner Nodes table inset from 24px to 16px per the Figma. Rebuild
the partner badge as a 20px circle (secondary-background-hover) with the 12px
provider icon centered so it fits without clipping, matching the Figma badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:06 -07:00
comfydesigner
90d7d9f450 style(workspace): flat partner icons, ghost member-row overflow, members column divider
Partner badges now render the bare flat provider icon (no bordered pill). The
per-member overflow (...) button returns to the ghost variant. Add a divider
under the members column labels; the label row sits above the scroll area so it
stays visible while the list scrolls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:04 -07:00
comfydesigner
d52e97b0dc style(workspace): fix filter button shade, give members overflow button a fill
The Partner Nodes filter button dropped the lighter selected-surface override so
it uses the standard secondary surface, matching the adjacent search and the
dialog close button. The members-tab overflow (...) button gains the filled
surface used by the other overflow menus instead of being backgroundless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:02 -07:00
comfydesigner
dc298c9ab8 feat(workspace): real provider icons on partner badges; keep selection after bulk toggle
Rebuild PartnerBadge on the shared BadgePill + getProviderIcon/getProviderBorderStyle
so partner rows show the same brand icons the node search menu uses, instead of
colored-initial placeholders.

Stop clearing the partner-node selection after a bulk enable/disable — the rows
stay selected so the switch can be flipped again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:00 -07:00
comfydesigner
9c3d2d3731 feat(workspace): Members header above card, list scrolls inside
Match the Partner Nodes layout: the search/invite/menu header sits above the
outlined card; the card fills the modal height and the members list scrolls
inside it instead of the whole card clipping. Footer stays below.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:59 -07:00
comfydesigner
6f44b98153 feat(workspace): Partner Nodes header above card, table scrolls inside; icon/label polish
Move the Partner Nodes title + search/filter above the outlined card; the card
now fills the modal height and the table scrolls inside it with a sticky header,
instead of the whole card clipping. Nav icons: Plan & Credits uses
lucide--receipt-text, Partner Nodes uses the comfy--node sidebar icon. Lowercase
'Partner nodes'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:57 -07:00
comfydesigner
51c213997f feat(workspace): real partner node list, brand badges, hover-reveal selection
Add shadcn-vue Checkbox and use it in the Partner Nodes table. Checkboxes are
hidden until a selection exists; with none selected they reveal on row hover
(the header select-all appears only once selecting). Give the filter button a
filled surface instead of the ghost variant. Add a PartnerBadge (brand colour +
initial) in the Partner column.

Populate the mock harness with the real Comfy Cloud partner-node catalog (96
nodes across 15 partners) pulled from the api-nodes list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:55 -07:00
comfydesigner
14c94879b1 fix(workspace): stabilize settings header height, lift Partner Nodes footer out of card
Use a uniform h-22 (88px) settings header on every tab so the 40px close button
sits a constant 24px from the top edge and no longer shifts when switching tabs;
the 48px workspace avatar centers ~20px from the top.

Move the Partner Nodes 'auto-enable new nodes' toggle outside the table card and
drop the bottom divider, matching the Figma (footer pinned to the panel bottom).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:54 -07:00
comfydesigner
fd6ec16c72 style(workspace): soften card outlines, unify overflow buttons to filled
Soften the workspace card borders (Plan & Credits, Members, Partner Nodes) to
interface-stroke/60 for a lighter panel. Standardize the members-row overflow
(...) button on the filled secondary style used in Plan & Credits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:52 -07:00
comfydesigner
974d79f776 feat(ui): shadcn Table for Partner Nodes, row-click select, fix Switch
Add a shadcn-vue Table component family (Table/Header/Body/Row/Head/Cell) and
rebuild the Partner Nodes table on it. Rows now hover and are clickable to
toggle their checkbox (a bigger target than the checkbox); the toggle cell and
checkbox stop propagation so they don't double-fire. Row dividers use the
interface-stroke token at reduced opacity for a subtler rule.

Fix the Switch: the thumb was bg-base-background (dark on a dark track, nearly
invisible) — now a white thumb on a primary/interface-stroke track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:50 -07:00
comfydesigner
6636851f79 fix(workspace): give the settings header 24px top breathing room
Add an optional headerHeightClass to BaseModalLayout (default h-18, unchanged
for other dialogs). SettingDialog bumps the header row to h-24 on workspace
panels so the 48px avatar sits 24px from the top while staying aligned with
the close button; nav and content headers move together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:49 -07:00
comfydesigner
3655901721 fix(workspace): align settings header with the dialog close button
Render the workspace avatar + name into the settings dialog's header row so
they sit on the same level as the close (X) button, matching Figma. Size the
avatar to 48px and the name to 24px/600 per the heading-text-medium spec.

Also fix WorkspaceProfilePic to merge its base classes via cn() so a caller's
size utility (e.g. size-12) reliably wins over the component's default size-8
instead of colliding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:47 -07:00
comfydesigner
6d91432abe chore(billing): trim mock harness to workspace-relevant controls (do not merge)
Remove the on/off toggle (harness is always active once opted in via ?billingmock;
the X still fully deactivates it) and the V0 checkout/op-flow controls
(subscribe, topup, op-poll). Keep ws, role, tier, state, balance, roleChange,
and the 2nd-workspace switcher toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:45 -07:00
comfydesigner
2b8981a2d3 feat(workspace): split settings into sidebar entries + Partner Nodes governance
Break the combined Workspace settings panel into three Settings sidebar
entries: Plan & Credits (keeps the 'workspace' key so deep links land),
Members, and a new Partner Nodes governance screen. Partner Nodes is gated to
Owner + Admins via a new canManagePartnerNodes permission; Members never see it.

Partner Nodes: per-node + bulk enable/disable, search, partner filter, sort,
and a workspace default for auto-enabling newly added nodes, backed by a new
partnerNodesApi + usePartnerNodes composable with optimistic updates. Adds a
reusable Switch. Mock routes added to the (do-not-merge) billing harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:43 -07:00
comfydesigner
4779a21ce5 chore(billing): restore preview billing mock harness (do not merge)
Dev-only harness for exercising billing/workspace UX on a cloud proxy with no
BE writes. No-op unless opted in via ?billingmock. Restored from 75da9212d^ to
back the V1 team-workspace prototype; must be excluded from the split PRs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:42 -07:00
comfydesigner
6b0e12a783 feat(workspace): rename non-creator Owner role to Admin
Reserve 'Owner' for the workspace creator (is_original_owner); every other
owner-role member now displays as 'Admin' in the members table and role menu.
Display-layer only — the backend role enum is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:40 -07:00
741 changed files with 17826 additions and 62183 deletions

View File

@@ -63,14 +63,3 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
"entry": ["src/main.ts", "index.html"],
"duplicates": {
"minOccurrences": 3,
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
},
"rules": {}
}

3
.gitattributes vendored
View File

@@ -6,6 +6,3 @@ packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
packages/ingest-types/src/types.gen.ts linguist-generated=true
packages/ingest-types/src/zod.gen.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true
# Vendored upstream workflow templates used as resolver test fixtures
src/renderer/extensions/firstRunTour/__fixtures__/templates/*.json linguist-generated=true

View File

@@ -73,8 +73,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -93,7 +93,7 @@ jobs:
# Run sharded tests (browsers pre-installed in container)
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
@@ -150,7 +150,7 @@ jobs:
# Run tests (browsers pre-installed in container)
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }}
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report

View File

@@ -40,6 +40,3 @@ jobs:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build
- name: Validate JSON-LD structured data
run: pnpm --filter @comfyorg/website validate:jsonld

View File

@@ -38,11 +38,9 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"

View File

@@ -278,49 +278,32 @@ jobs:
continue
fi
# Create backport branch. A failure here (e.g. dirty state left
# by a prior target) must not abort the loop and skip remaining
# targets, so fall back to a clean checkout and record the error.
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
git checkout main || git checkout -f main
echo "::endgroup::"
continue
fi
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
git push --force-with-lease origin "${BACKPORT_BRANCH}"
else
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
git push origin "${BACKPORT_BRANCH}"
fi
# A push failure for one target must not abort the loop and
# prevent remaining targets from being attempted.
if "${PUSH_CMD[@]}"; then
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
else
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
git checkout main || git checkout -f main
git checkout main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort || true
git cherry-pick --abort
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
git checkout main
git branch -D "${BACKPORT_BRANCH}"
fi
echo "::endgroup::"
@@ -401,10 +384,6 @@ jobs:
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
The auto-backport could not be completed automatically. Please backport
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
open a PR to `${target}`.
<details>
<summary>📄 Conflicting files</summary>
@@ -437,37 +416,19 @@ jobs:
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
# Post a comment without letting a single failed `gh pr comment` (e.g.
# a locked issue, as happened for PR #13359, or a transient API error)
# abort the step under `set -e` and swallow the remaining failures.
post_comment() {
local body="$1"
local context="$2"
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
fi
}
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
if [ "${reason}" = "branch-missing" ]; then
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
elif [ "${reason}" = "already-exists" ]; then
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
elif [ "${reason}" = "branch-create-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
elif [ "${reason}" = "push-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
@@ -483,10 +444,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

View File

@@ -29,7 +29,7 @@ jobs:
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
with:
# Overriding diff_excludes replaces the reusable default wholesale, so
# this restates the generated/vendored defaults and adds this repo's heavy
@@ -48,7 +48,7 @@ jobs:
:!**/*-snapshots/**
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
# Load the prompts/scripts from the same ref as `uses:`.
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
# Optional — enables start/complete Slack DMs to the triggerer.

View File

@@ -23,10 +23,6 @@ on:
required: false
default: 'Comfy-Org/ComfyUI'
type: string
target_branch:
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
required: false
type: string
jobs:
check-release-week:
@@ -53,15 +49,12 @@ jobs:
fi
- name: Summary
env:
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -110,7 +103,6 @@ jobs:
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail

1
.gitignore vendored
View File

@@ -16,7 +16,6 @@ yarn.lock
.eslintcache
.prettiercache
.stylelintcache
.fallow/
node_modules
.pnpm-store

View File

@@ -88,11 +88,6 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/composables/useWorkspaceUI',
replacement:
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:

View File

@@ -10,13 +10,11 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
...LOCALE_PREFIXES.flatMap((prefix) =>
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
])
)
)
function isExcludedFromSitemap(page: string): boolean {
const pathname = new URL(page).pathname.replace(/\/$/, '')

View File

@@ -76,14 +76,10 @@ test.describe('Affiliates landing — desktop interactions', () => {
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
'@type': string
mainEntity?: unknown[]
}[]
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({

View File

@@ -1,51 +0,0 @@
import { expect } from '@playwright/test'
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
import { test } from './fixtures/blockExternalMedia'
test.describe('Brand portal @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/brand')
})
test('renders each brand guideline section', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'One mark, many dimensions.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Every color earns its place.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Precise, never cute.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Trademark guidelines.' })
).toBeVisible()
})
test('shows all four marks', async ({ page }) => {
const logos = page.locator('#logos')
for (const name of [
'Core Logo',
'Logomark',
'Icon',
'Amplified Logomark'
]) {
await expect(logos.getByText(name, { exact: true })).toBeVisible()
}
})
test('the hero ctas open the gated guidelines and the logo bundle', async ({
page
}) => {
await expect(
page.getByRole('link', { name: 'View brand guidelines' })
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
await expect(
page.getByRole('link', { name: 'Download logos' })
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
})
})

View File

@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers - Comfy')
await expect(page).toHaveTitle('Careers Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 - Comfy')
await expect(page).toHaveTitle('招聘 Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()

View File

@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud - supported by default'
'Custom-node packs on Comfy Cloud supported by default'
)
})

View File

@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
await expect(page).toHaveTitle('Comfy Cloud AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 6 model cards are visible', async ({
test('AIModelsSection heading and 5 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(6)
await expect(modelCards).toHaveCount(5)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

View File

@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop - Run AI on Your Hardware'
'Download Comfy Desktop Run AI on Your Hardware'
)
})

View File

@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
await expect(page).toHaveTitle('Comfy Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {

View File

@@ -1,158 +0,0 @@
import { expect } from '@playwright/test'
import { learningTutorials } from '../src/data/learningTutorials'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
test.describe('Learning page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learning')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning - Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
await expect(heading).toContainText('ComfyUI')
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
})
test('featured workflow section shows title and author', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.featured.title', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByText(t('learning.featured.author', 'en'))
).toBeVisible()
})
test('renders every tutorial from the data source', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'en'),
level: 2
})
).toBeVisible()
for (const tutorial of learningTutorials) {
await expect(
page.getByRole('button', {
name: tutorialButtonName(tutorial.title.en, 'en')
})
).toBeVisible()
}
})
test('tutorials with a workflow link expose an external Try Workflow link', async ({
page
}) => {
const linkedTutorials = learningTutorials.filter(
(tutorial) => tutorial.href
)
const workflowLinks = page.getByRole('link', {
name: t('cta.tryWorkflow', 'en')
})
const hrefs = await workflowLinks.evaluateAll((links) =>
links.map((link) => link.getAttribute('href'))
)
for (const tutorial of linkedTutorials) {
expect(hrefs).toContain(tutorial.href)
}
})
test('call to action links to contact sales', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.cta.heading', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
).toHaveAttribute('href', '/contact')
})
})
test.describe('Learning tutorial dialog', () => {
test('opens a tutorial video and dismisses via the close button', async ({
page
}) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
// TutorialsSection is hydrated via `client:visible`; retry the click until
// Vue responds by opening the dialog.
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await expect(
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
).toBeVisible()
await dialog
.getByRole('button', { name: t('gallery.detail.close', 'en') })
.click()
await expect(dialog).toBeHidden()
})
test('dismisses the dialog with the Escape key', async ({ page }) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
})
test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 - Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'zh-CN'),
level: 2
})
).toBeVisible()
const [firstTutorial] = learningTutorials
await expect(
page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
})
).toBeVisible()
})
})

View File

@@ -1,112 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
test.describe('MCP page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mcp')
})
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
page
}) => {
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
await expect(installLinks).toHaveCount(2)
for (const link of await installLinks.all()) {
await expect(link).toHaveAttribute('href', '#setup')
}
})
test('setup section shows both install options', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toBeVisible()
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
test('client tabs swap install instructions', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
// Claude Code is the default tab and carries the CLI command
await expect(
setup.getByRole('tab', { name: 'Claude Code' })
).toHaveAttribute('data-state', 'active')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
await expect(activePanel).toContainText('Add custom connector')
await setup.getByRole('tab', { name: 'Cursor' }).click()
await expect(activePanel).toContainText('X-API-Key')
await expect(
activePanel.getByRole('link', { name: 'platform.comfy.org' })
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
})
test('skills plugin link lives in the agent option card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('link', { name: 'View on GitHub' })
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
})
test('capabilities section shows all six tool cards', async ({ page }) => {
for (const title of [
'Generate anything',
'Search the ecosystem',
'Run real workflows',
'Direct any model',
'Generate in batches',
'Ship it as an app'
]) {
await expect(
page.getByRole('heading', { name: title, exact: true })
).toBeVisible()
}
})
test('FAQ lists nine questions and autolinks the server URL', async ({
page
}) => {
const triggers = page.locator('[id^="faq-trigger-"]')
await triggers.first().scrollIntoViewIfNeeded()
await expect(triggers).toHaveCount(9)
await page.getByRole('button', { name: "What's the server URL?" }).click()
await expect(
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
).toHaveAttribute('href', MCP_ENDPOINT)
})
})
test.describe('MCP page zh-CN @smoke', () => {
test('setup section renders localized options', async ({ page }) => {
await page.goto('/zh-CN/mcp')
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(setup.getByText('方式一')).toBeVisible()
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
})

View File

@@ -31,26 +31,6 @@ test.describe('Desktop navigation @smoke', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of ['Products', 'Community']) {
await expect(
desktopLinks
.getByRole('button', { name: label })
.getByText('NEW', { exact: true })
).toBeVisible()
}
await expect(
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
@@ -137,27 +117,6 @@ test.describe('Mobile menu @mobile', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
for (const label of ['Products', 'Community']) {
await expect(
menu.getByRole('button', { name: label }).getByText('NEW', {
exact: true
})
).toBeVisible()
}
await expect(
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {

View File

@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful - Comfy')
await expect(page).toHaveTitle('Payment Successful Comfy')
await expectNoIndex(page)
})
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed - Comfy')
await expect(page).toHaveTitle('Payment Failed Comfy')
await expectNoIndex(page)
})
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 - Comfy')
await expect(page).toHaveTitle('支付成功 Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 - Comfy')
await expect(page).toHaveTitle('支付失败 Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -17,8 +17,7 @@
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts",
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
"generate:models": "tsx ./scripts/generate-models.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",

View File

@@ -1,4 +0,0 @@
<svg width="275" height="275" viewBox="0 0 275 275" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="274.66" height="274.66" rx="63.5555" fill="#211927"/>
<path d="M177.456 174.409C177.713 173.538 177.854 172.621 177.854 171.656C177.854 166.313 173.546 161.983 168.232 161.983H125.108C122.791 162.006 120.894 160.124 120.894 157.794C120.894 157.37 120.965 156.97 121.058 156.594L132.67 115.926C133.162 114.137 134.801 112.819 136.72 112.819L180.008 112.772C189.138 112.772 196.84 106.582 199.158 98.1335L205.666 75.4696C205.877 74.6695 205.994 73.7987 205.994 72.9279C205.994 67.6091 201.71 63.3022 196.419 63.3022H144.048C134.965 63.3022 127.286 69.4448 124.921 77.7996L120.52 93.2618C120.005 95.0269 118.389 96.3213 116.47 96.3213H103.898C94.8846 96.3213 87.276 102.346 84.8412 110.607L69.0152 166.172C68.7811 166.996 68.6641 167.89 68.6641 168.784C68.6641 174.127 72.9717 178.457 78.2861 178.457H90.6472C92.9649 178.457 94.8612 180.34 94.8612 182.693C94.8612 183.093 94.8144 183.494 94.6973 183.87L90.3194 199.191C90.1087 200.015 89.9683 200.862 89.9683 201.733C89.9683 207.052 94.2525 211.359 99.5434 211.359L151.938 211.312C161.045 211.312 168.724 205.145 171.065 196.744L177.432 174.433L177.456 174.409Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,129 +0,0 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { collectGraphIds } from '../src/utils/jsonLd'
const DIST_DIR = join(process.cwd(), 'dist')
const JSON_LD_BLOCK =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
interface Violation {
file: string
message: string
}
function htmlFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true })
.map(String)
.filter((entry) => entry.endsWith('.html'))
.map((entry) => join(dir, entry))
}
function typesOf(node: Record<string, unknown>): string[] {
const type = node['@type']
if (typeof type === 'string') return [type]
if (Array.isArray(type)) {
return type.filter((t): t is string => typeof t === 'string')
}
return []
}
function hasValidPrice(node: Record<string, unknown>): boolean {
const price = node.price
const priceStr = price == null ? '' : String(price).trim()
return priceStr !== '' && !Number.isNaN(Number(priceStr))
}
function checkHonesty(
value: unknown,
file: string,
violations: Violation[]
): void {
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (!node || typeof node !== 'object') return
const record = node as Record<string, unknown>
const types = typesOf(record)
if (types.includes('Review') || types.includes('AggregateRating')) {
violations.push({
file,
message: `dishonest node type ${types.join('/')}`
})
}
if ('aggregateRating' in record || 'review' in record) {
violations.push({
file,
message: 'node carries a review/aggregateRating'
})
}
if (
types.includes('Offer') &&
(!hasValidPrice(record) || !record.priceCurrency)
) {
violations.push({
file,
message: 'Offer missing priceCurrency or a concrete price'
})
}
Object.values(record).forEach(walk)
}
walk(value)
}
function validateFile(file: string): Violation[] {
const html = readFileSync(file, 'utf8')
const violations: Violation[] = []
const definedIds = new Set<string>()
const referencedIds: string[] = []
for (const match of html.matchAll(JSON_LD_BLOCK)) {
let parsed: unknown
try {
parsed = JSON.parse(match[1])
} catch (error) {
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
continue
}
checkHonesty(parsed, file, violations)
const { defined, references } = collectGraphIds(parsed)
defined.forEach((id) => definedIds.add(id))
referencedIds.push(...references)
}
for (const id of referencedIds) {
if (!definedIds.has(id)) {
violations.push({ file, message: `unresolved @id reference: ${id}` })
}
}
return violations
}
function main(): void {
const files = htmlFiles(DIST_DIR)
if (files.length === 0) {
console.error(
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
)
process.exit(1)
}
const violations = files.flatMap(validateFile)
if (violations.length > 0) {
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
for (const { file, message } of violations) {
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
}
process.exit(1)
}
process.stdout.write(
`JSON-LD validation passed across ${files.length} page(s).\n`
)
}
main()

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, reactive, watch } from 'vue'
import { reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
@@ -9,31 +9,6 @@ const { faqs } = defineProps<{
faqs: readonly Faq[]
}>()
type AnswerPart = { type: 'text' | 'link'; value: string }
function parseAnswer(answer: string): AnswerPart[] {
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
const parts: AnswerPart[] = []
let lastIndex = 0
for (const match of answer.matchAll(urlPattern)) {
const start = match.index ?? 0
const url = match[0].replace(/[.,;:]+$/, '')
if (start > lastIndex) {
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
}
parts.push({ type: 'link', value: url })
lastIndex = start + url.length
}
if (lastIndex < answer.length) {
parts.push({ type: 'text', value: answer.slice(lastIndex) })
}
return parts
}
const parsedFaqs = computed(() =>
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
)
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
@@ -65,7 +40,7 @@ function toggle(index: number) {
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in parsedFaqs"
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-b border-primary-comfy-canvas/20"
>
@@ -108,23 +83,8 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
>
<template
v-for="(part, partIndex) in faq.answerParts"
:key="partIndex"
>
<a
v-if="part.type === 'link'"
:href="part.value"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
>{{ part.value }}</a
>
<template v-else>{{ part.value }}</template>
</template>
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
</p>
</section>
</div>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
variant?: 'default' | 'outline'
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
type ColumnCount = 2 | 3 | 4
const {
cards,
columns = 3,
copiedLabel,
copyLabel,
eyebrow,
heading,
subtitle
} = defineProps<{
cards: readonly FeatureCard[]
columns?: ColumnCount
copiedLabel?: string
copyLabel?: string
eyebrow?: string
heading: string
subtitle?: string
}>()
const columnClass: Record<ColumnCount, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
:variant="card.action.variant ?? 'outline'"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -8,7 +8,7 @@ import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' }
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
@@ -20,7 +20,6 @@ type RowMedia =
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
}
export interface FeatureRow {
@@ -59,7 +58,7 @@ const {
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12',
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
@@ -73,11 +72,10 @@ const {
</div>
<!-- Media: image or video -->
<!-- 620/364 and w-155 (620px) match the card media asset dimensions -->
<div
:class="
cn(
'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0',
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
@@ -88,12 +86,7 @@ const {
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
:class="
cn(
'absolute inset-0 size-full rounded-4xl',
row.media.fit === 'contain' ? 'object-contain' : 'object-cover'
)
"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
@@ -106,13 +99,7 @@ const {
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
:fit="row.media.fit"
:class="
cn(
'absolute inset-0 size-full',
row.media.fit === 'contain' && 'bg-transparent'
)
"
class="w-full"
/>
</div>
</GlassCard>

View File

@@ -1,41 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HeroBackdrop01 from './HeroBackdrop01.vue'
const sampleImage =
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
const meta: Meta<typeof HeroBackdrop01> = {
title: 'Website/Blocks/HeroBackdrop01',
component: HeroBackdrop01,
tags: ['autodocs'],
args: {
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
title: 'Build anything\nwith ComfyUI',
subtitle:
'A powerful, modular visual interface for building and running AI workflows.'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithBadge: Story = {
args: {
badgeText: 'New'
}
}
export const WithFootnote: Story = {
args: {
footnote: 'Available on Windows, macOS, and Linux.'
}
}
export const NoBackdrop: Story = {
args: {
backdrop: undefined
}
}

View File

@@ -1,193 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
type Backdrop =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
backdrop,
mobileBackdrop,
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
subtitle,
footnote,
class: className
} = defineProps<{
backdrop?: Backdrop
mobileBackdrop?: Backdrop
badgeText?: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
subtitle?: string
footnote?: string
class?: HTMLAttributes['class']
}>()
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
// Removing the reactive `autoplay` attribute only suppresses the *initial*
// play; it can't pause a video the browser has already started. That is
// exactly the SSR case: the server renders `autoplay` (it can't read the
// client's motion preference), the browser begins playback on parse, and the
// post-hydration attribute removal is too late. Pause on mount so
// reduced-motion users get the poster frame instead of a looping video.
const pauseIfReduced = (el: unknown) => {
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
}
// On mobile the backdrop is an in-flow rounded card above the content; on
// desktop it is the full-bleed background behind it. A single element serves
// both roles via responsive classes — mobileBackdrop only swaps the source.
const sharedBackdropClass =
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
// When both breakpoints use images, serve them from a single responsive <img>
// so the browser fetches only the source matching the viewport. Two
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
// does not stop the fetch), doubling the high-priority load on an
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
// way and fall back to breakpoint-toggled layers below.
const responsiveImage = computed(() => {
if (backdrop?.type !== 'image') return null
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
const base = mobileBackdrop ?? backdrop
return {
src: base.src,
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
// Larger-viewport source; omitted when one image serves both breakpoints.
desktopSrc: mobileBackdrop ? backdrop.src : undefined
}
})
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
const backdropLayers = computed(() => {
if (!backdrop) return []
if (mobileBackdrop) {
return [
{
backdrop: mobileBackdrop,
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
},
{
backdrop,
class: 'absolute inset-0 hidden size-full object-cover lg:block'
}
]
}
return [{ backdrop, class: sharedBackdropClass }]
})
const scrimShape = 'farthest-side at 50% 50%'
const scrimStyle = {
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
}
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
>
<div class="relative overflow-hidden rounded-3xl">
<slot name="backdrop">
<picture v-if="responsiveImage" class="contents">
<source
v-if="responsiveImage.desktopSrc"
:srcset="responsiveImage.desktopSrc"
media="(min-width: 1024px)"
/>
<img
:src="responsiveImage.src"
:alt="responsiveImage.alt"
fetchpriority="high"
decoding="async"
:class="sharedBackdropClass"
/>
</picture>
<template v-else>
<template v-for="(layer, i) in backdropLayers" :key="i">
<video
v-if="layer.backdrop.type === 'video'"
:ref="pauseIfReduced"
:src="layer.backdrop.src"
:poster="layer.backdrop.poster"
:aria-label="layer.backdrop.alt"
:aria-hidden="layer.backdrop.alt ? undefined : true"
:autoplay="!reduceMotion"
loop
muted
playsinline
preload="metadata"
:class="layer.class"
/>
<img
v-else
:src="layer.backdrop.src"
:alt="layer.backdrop.alt ?? ''"
fetchpriority="high"
decoding="async"
:class="layer.class"
/>
</template>
</template>
</slot>
<div
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
>
<div class="relative w-full max-w-xl">
<div
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
/>
<div class="relative">
<ProductHeroBadge
v-if="badgeText"
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
>
{{ subtitle }}
</p>
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
{{ footnote }}
</p>
<slot />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
const { resources } = defineProps<{
resources: { label: string; href: string; display: string }[]
}>()
</script>
<template>
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
<li v-for="resource in resources" :key="resource.href">
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
<span class="text-primary-warm-gray"> </span>
<a
:href="resource.href"
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
>
{{ resource.display }}
</a>
</li>
</ul>
</template>

View File

@@ -33,7 +33,7 @@ const ctaButtons = [
<template>
<nav
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

@@ -16,7 +16,6 @@ import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
import NewBadge from './NewBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mainNavigation = getMainNavigation(locale)
@@ -43,10 +42,7 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
>
<span class="inline-flex items-center gap-1">
<span class="ppformula-text-center">{{ navItem.label }}</span>
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
</span>
{{ navItem.label }}
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max gap-16">

View File

@@ -8,7 +8,6 @@ import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import NewBadge from './NewBadge.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
@@ -97,8 +96,7 @@ onUnmounted(() => {
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
{{ item.label }}
<template #append>
<ChevronRight class="size-7" />
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import { ArrowUpRight } from '@lucide/vue'
import type { NavColumnItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NewBadge from './NewBadge.vue'
import { t } from '../../../i18n/translations'
defineProps<{ item: NavColumnItem; locale: Locale }>()
</script>
@@ -11,7 +12,9 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
<template>
<span class="flex items-center gap-2">
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
<Badge v-if="item.badge" size="xs" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
<ArrowUpRight
v-if="item.external"
class="text-primary-comfy-yellow size-4"

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import type { BadgeVariants } from '@/components/ui/badge'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
</script>
<template>
<Badge :size="size" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
</template>

View File

@@ -1,12 +0,0 @@
---
import type { JsonLdGraph } from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
interface Props {
graph: JsonLdGraph
}
const { graph } = Astro.props
---
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />

View File

@@ -30,12 +30,7 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<Button
as="span"
variant="default"
size="sm"
class="mt-4 h-auto whitespace-normal"
>
<Button as="span" variant="default" size="sm" class="mt-4">
{{ cta }}
</Button>
</div>

View File

@@ -85,9 +85,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
links: [
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.brand', locale), href: routes.brand },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
]
}
@@ -176,7 +174,10 @@ const contactColumn: { title: string; links: FooterLink[] } = {
</div>
<!-- Logo -->
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
<canvas
ref="canvasRef"
class="pointer-events-none size-52 opacity-80 lg:mt-28"
/>
</div>
</footer>
</template>

View File

@@ -10,7 +10,6 @@ import {
whenever
} from '@vueuse/core'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import type { HTMLAttributes } from 'vue'
import { t } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
@@ -31,9 +30,7 @@ const {
autoplay = false,
loop = false,
minimal = false,
hideControls = false,
fit = 'cover',
class: className
hideControls = false
} = defineProps<{
locale?: Locale
src?: string
@@ -43,8 +40,6 @@ const {
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
class?: HTMLAttributes['class']
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -194,12 +189,7 @@ function toggleFullscreen() {
<template>
<div
ref="playerEl"
:class="
cn(
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
className
)
"
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
@pointermove="showControls"
@pointerdown="showControls"
@focusin="showControls"
@@ -207,9 +197,7 @@ function toggleFullscreen() {
<video
v-if="src"
ref="videoEl"
:class="
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
"
class="size-full object-cover"
:src
:poster
:preload="autoplay ? 'auto' : 'metadata'"

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { localizeHref } from '../../config/routes'
import { t } from '../../i18n/translations'
const {
@@ -16,7 +15,8 @@ const {
locale?: Locale
}>()
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
const localePrefix = locale === 'en' ? '' : `/${locale}`
const nextHref = `${localePrefix}/demos/${nextSlug}`
</script>
<template>

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import Button from '../ui/button/Button.vue'
const { href, label } = defineProps<{
href: string
label: string
}>()
</script>
<template>
<div class="mt-2 flex justify-center">
<Button as="a" :href variant="default" size="lg">
{{ label }}
</Button>
</div>
</template>

View File

@@ -1,25 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { title, class: className } = defineProps<{
title: string
class?: HTMLAttributes['class']
}>()
const { title } = defineProps<{ title: string }>()
</script>
<template>
<section
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
>
<h1
:class="
cn(
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
className
)
"
>
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
{{ title }}
</h1>
</section>

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getRoutes } from '../../config/routes'
import { hasKey, translationKeys } from '../../i18n/translations'
const PREFIX = 'enterprise-msa'
function deriveMsaSectionIds(): string[] {
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
const ids: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !ids.includes(match[1])) ids.push(match[1])
}
return ids
}
describe('enterprise MSA i18n', () => {
it('every derived section has a title and at least one block', () => {
const sectionIds = deriveMsaSectionIds()
expect(sectionIds.length).toBeGreaterThan(0)
for (const id of sectionIds) {
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
}
})
it('exposes the page-chrome keys the .astro file references', () => {
for (const suffix of [
'effective-date',
'page.title',
'page.description',
'page.heading',
'page.tocLabel',
'page.effectiveDateLabel',
'page.parties'
]) {
expect(hasKey(`${PREFIX}.${suffix}`)).toBe(true)
}
})
it('serves the enterprise MSA at the canonical /enterprise-msa path regardless of locale', () => {
expect(getRoutes('en').enterpriseMsa).toBe('/enterprise-msa')
expect(getRoutes('zh-CN').enterpriseMsa).toBe('/enterprise-msa')
})
})

View File

@@ -6,15 +6,14 @@ export const badgeVariants = cva({
variants: {
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]',
xxs: 'px-1.5 py-px text-[8px]'
xs: 'px-2 py-0.5 text-[9px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
}
},
defaultVariants: {

View File

@@ -20,8 +20,6 @@ export const buttonVariants = cva(
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
underlineLink:
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
inline:
'text-primary-comfy-yellow inline h-auto rounded-none p-0 align-baseline text-sm font-normal tracking-normal whitespace-normal hover:opacity-90 [&>span]:top-0 [&>span]:underline',
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
navMuted:
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'

View File

@@ -1,10 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
@@ -14,8 +11,6 @@ const {
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const multiline = computed(() => value.includes('\n'))
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
@@ -25,32 +20,15 @@ function handleCopy() {
<template>
<div
:class="
cn(
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
multiline ? 'items-start' : 'items-center'
)
"
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
>
<span
:class="
cn(
'flex-1 font-mono text-xs text-primary-comfy-canvas',
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
)
"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
:class="
cn(
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
multiline && 'mt-0.5'
)
"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />

View File

@@ -1,38 +0,0 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { IconButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { iconButtonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: IconButtonVariants['variant']
size?: IconButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="icon-button"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(iconButtonVariants({ variant, size }), className)"
>
<slot />
</Primitive>
</template>

View File

@@ -1,28 +0,0 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const iconButtonVariants = cva(
[
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
],
{
variants: {
variant: {
ghost:
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
},
size: {
sm: 'size-8',
default: 'size-10',
lg: 'size-14'
}
},
defaultVariants: {
variant: 'ghost',
size: 'default'
}
}
)
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>

View File

@@ -1,75 +0,0 @@
import { onMounted, ref } from 'vue'
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
type ClosedBanners = Record<string, boolean>
function readClosedBanners(): ClosedBanners {
try {
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
} catch {
return {}
}
}
function writeClosedBanners(value: ClosedBanners): void {
try {
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
} catch {
// Storage unavailable (private mode / quota) — dismissal just won't persist.
}
}
/** The stable part of a version key (everything before `_v<hash>`). */
function versionPrefix(version: string): string {
const idx = version.lastIndexOf('_v')
return idx === -1 ? version : version.slice(0, idx)
}
/**
* Client-side dismissal persisted in localStorage, keyed by a content-aware
* `version`. The banner renders visible in the static HTML (so non-dismissers
* see no pop-in); an inline pre-hydration script hides an already-dismissed
* banner before paint, and this composable then removes it from the DOM on mount.
*/
export function useBannerDismissal(version: string) {
const isVisible = ref(true)
onMounted(() => {
const stored = readClosedBanners()
const prefix = versionPrefix(version)
// Prune stale versions of THIS banner+locale; keep other banners/locales
// and the current version.
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
let pruned = false
for (const key of Object.keys(stored)) {
if (versionPrefix(key) !== prefix || key === version) {
cleaned[key] = stored[key]
} else {
pruned = true
}
}
if (pruned) writeClosedBanners(cleaned)
isVisible.value = !cleaned[version]
})
function close(): void {
isVisible.value = false
const stored = readClosedBanners()
stored[version] = true
writeClosedBanners(stored)
}
// Call once the close transition has finished. Sets the pre-paint hide signal
// so the banner doesn't flash back in on a ClientRouter (view-transition)
// navigation — where the inline <head> script does not re-run but <html>
// persists. Deferred to after the animation so the leave transition can play.
function persistHidden(): void {
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
}
return { isVisible, close, persistHidden }
}

View File

@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isHrefActive } from './useCurrentPath'
describe('isHrefActive', () => {
it('matches the current page', () => {
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
})
it('does not match other pages', () => {
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
})
it('matches regardless of a trailing slash', () => {
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
})
it('ignores query and hash on the href', () => {
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
})
it('never matches an external href', () => {
expect(
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
).toBe(false)
})
it('never matches an empty href', () => {
expect(isHrefActive('', '/mcp')).toBe(false)
})
})

View File

@@ -1,85 +0,0 @@
import type { ButtonVariants } from '../components/ui/button'
import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
import { localizeHref } from './routes'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
// window gates on the last deploy, not the visitor's exact clock.
interface BannerLinkConfig {
readonly href: string
readonly titleKey: TranslationKey
readonly target?: boolean
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerConfig {
readonly id: string
readonly isActive: boolean
readonly startsAt?: string
readonly endsAt?: string
/** Empty/undefined = all locales. */
readonly targetLocales?: readonly Locale[]
/** v1 only supports 'sitewide'. */
readonly targetSections?: readonly string[]
readonly titleKey: TranslationKey
readonly descriptionKey?: TranslationKey
readonly link?: BannerLinkConfig
}
interface BannerLinkData {
readonly href: string
readonly title: string
readonly target?: '_blank'
readonly rel?: string
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerData {
readonly id: string
readonly title: string
readonly description?: string
readonly link?: BannerLinkData
}
export const bannerConfig: BannerConfig = {
id: 'announcement',
isActive: true,
targetSections: ['sitewide'],
titleKey: 'launches.banner.text',
link: {
href: '/mcp',
titleKey: 'launches.banner.cta',
buttonVariant: 'underlineLink'
}
}
/** Resolve a config's i18n keys into display strings for the given locale. */
export function getBannerData(
config: BannerConfig,
locale: Locale
): BannerData {
const { link } = config
const target = link?.target ? '_blank' : undefined
return {
id: config.id,
title: t(config.titleKey, locale),
description: config.descriptionKey
? t(config.descriptionKey, locale)
: undefined,
link: link
? {
href: localizeHref(link.href, locale),
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),
buttonVariant: link.buttonVariant
}
: undefined
}
}

View File

@@ -1,53 +0,0 @@
import { t } from '../i18n/translations'
import type { Locale, TranslationKey } from '../i18n/translations'
import { externalLinks } from './routes'
interface PricingTier {
slug: string
labelKey: TranslationKey
priceKey: TranslationKey
}
const tiers: PricingTier[] = [
{
slug: 'standard',
labelKey: 'pricing.plan.standard.label',
priceKey: 'pricing.plan.standard.price'
},
{
slug: 'creator',
labelKey: 'pricing.plan.creator.label',
priceKey: 'pricing.plan.creator.price'
},
{
slug: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price'
}
]
export interface PricingOffer {
name: string
price: string
url: string
}
export function pricingOffers(locale: Locale): PricingOffer[] {
return tiers.flatMap((tier) => {
const display = t(tier.priceKey, locale).trim()
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
if (!match) {
console.warn(
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
)
return []
}
return [
{
name: t(tier.labelKey, locale),
price: match[1],
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
}
]
})
}

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest'
import { localizeHref } from './routes'
describe('localizeHref', () => {
it('prefixes an internal path for a non-default locale', () => {
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
})
it('leaves the default locale unprefixed', () => {
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
})
it('passes external URLs through unchanged', () => {
expect(
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
).toBe('https://docs.comfy.org/agent-tools/cloud')
})
it('never prefixes locale-invariant routes', () => {
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
})
})

View File

@@ -15,14 +15,12 @@ const baseRoutes = {
demos: '/demos',
learning: '/learning',
termsOfService: '/terms-of-service',
enterpriseMsa: '/enterprise-msa',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models',
mcp: '/mcp',
brand: '/brand'
mcp: '/mcp'
} as const
type Routes = typeof baseRoutes
@@ -37,37 +35,19 @@ type Routes = typeof baseRoutes
// block in src/i18n/translations.ts for the reasoning.
//
// termsOfService: legal-reviewed English-only document, same reasoning.
//
// enterpriseMsa: legal-reviewed English-only document (Comfy Enterprise
// Customer Agreement template), same reasoning. See the comment header
// in src/pages/enterprise-msa.astro.
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'affiliates',
'affiliateTerms',
'termsOfService',
'enterpriseMsa'
'termsOfService'
])
const LOCALE_INVARIANT_PATHS = new Set<string>(
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
)
/**
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
* URLs and locale-invariant routes pass through unchanged.
*/
export function localizeHref(href: string, locale: Locale = 'en'): string {
if (locale === 'en' || !href.startsWith('/')) return href
if (LOCALE_INVARIANT_PATHS.has(href)) return href
return `/${locale}${href}`
}
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([key, path]) => [
key,
localizeHref(path, locale)
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}
@@ -80,23 +60,18 @@ export const externalLinks = {
cloudStatus: 'https://status.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
g2ComfyUi: 'https://www.g2.com/products/comfyui',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpEndpoint: 'https://cloud.comfy.org/mcp',
mcpServer: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'

View File

@@ -1,7 +1,5 @@
import type { LocalizedText } from '../i18n/translations'
import { BRAND_ASSETS_ZIP } from './brandAssets'
interface AffiliateBrandAsset {
id: string
title: LocalizedText
@@ -9,6 +7,9 @@ interface AffiliateBrandAsset {
preview: string
}
const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
{
id: 'core-logo',

View File

@@ -1,9 +0,0 @@
// Shared brand download URLs served from the media bucket, used by both the
// affiliate page and the brand portal.
export const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
// Brand guidelines live in Google Drive, shared to Comfy Org only, so Google
// enforces the comfy.org sign-in. Opened in a new tab rather than downloaded.
export const BRAND_GUIDELINES_PDF =
'https://drive.google.com/file/d/1EDt03JTfF_nbbY_H2n67aaUj6k11v3bS/view'

View File

@@ -1,91 +0,0 @@
interface BrandColor {
name: string
hex: string
rgb: string
hsl: string
cmyk: string
swatchClass: string
textClass: string
wide?: boolean
border?: boolean
}
export const brandColors: readonly BrandColor[] = [
{
name: 'Comfy Yellow',
hex: '#F2FF59',
rgb: '242, 255, 89',
hsl: '65, 100, 67',
cmyk: '5, 0, 65, 0',
swatchClass: 'bg-primary-comfy-yellow',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Comfy Ink',
hex: '#211927',
rgb: '33, 25, 39',
hsl: '274, 22, 13',
cmyk: '15, 36, 0, 85',
swatchClass: 'bg-primary-comfy-ink',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Comfy Canvas',
hex: '#C2BFB9',
rgb: '194, 191, 185',
hsl: '40, 7, 74',
cmyk: '0, 2, 5, 24',
swatchClass: 'bg-primary-comfy-canvas',
textClass: 'text-primary-comfy-ink'
},
{
name: 'Comfy Plum',
hex: '#49378B',
rgb: '73, 55, 139',
hsl: '253, 43, 38',
cmyk: '47, 60, 0, 45',
swatchClass: 'bg-primary-comfy-plum',
textClass: 'text-primary-comfy-canvas'
},
{
name: 'Warm White',
hex: '#F0EFED',
rgb: '240, 239, 237',
hsl: '40, 9, 94',
cmyk: '0, 0, 1, 6',
swatchClass: 'bg-primary-warm-white',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Warm Gray',
hex: '#7E7C78',
rgb: '126, 124, 120',
hsl: '40, 2, 48',
cmyk: '0, 2, 5, 51',
swatchClass: 'bg-primary-warm-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Cool Gray',
hex: '#3C3C3C',
rgb: '60, 60, 60',
hsl: '0, 0, 24',
cmyk: '0, 0, 0, 76',
swatchClass: 'bg-secondary-cool-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Mauve',
hex: '#4D3762',
rgb: '77, 55, 98',
hsl: '271, 28, 30',
cmyk: '21, 44, 0, 62',
swatchClass: 'bg-secondary-mauve',
textClass: 'text-primary-warm-white'
}
] as const

View File

@@ -72,24 +72,6 @@ export const drops: readonly Drop[] = [
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
@@ -130,6 +112,24 @@ export const drops: readonly Drop[] = [
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'community-workflows',
category: COMMUNITY,

View File

@@ -30,23 +30,15 @@ export type NavItem =
label: string
columns: NavColumn[]
featured?: NavFeatured
badge?: 'new'
href?: never
}
| {
label: string
href: string
badge?: 'new'
columns?: never
featured?: never
}
| { label: string; href: string; columns?: never; featured?: never }
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
return [
{
label: t('nav.products', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
@@ -103,7 +95,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),

File diff suppressed because it is too large Load Diff

View File

@@ -5,19 +5,8 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import { isHrefActive } from '../composables/useCurrentPath'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { buildPageGraph, pageContext } from '../utils/jsonLd'
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
interface Props {
title: string
@@ -25,10 +14,6 @@ interface Props {
keywords?: string[]
ogImage?: string
noindex?: boolean
pageType?: WebPageType
breadcrumbs?: Crumb[]
mainEntityId?: string
extraJsonLd?: (JsonLdNode | null | undefined)[]
}
const {
@@ -37,54 +22,43 @@ const {
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
pageType,
breadcrumbs,
mainEntityId,
extraJsonLd,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const canonicalURL = new URL(url)
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
// A promo never advertises the page you are already on, so the banner is
// suppressed when its CTA points at the current path.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible =
evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
const structuredData = noindex
? undefined
: buildPageGraph(
{ siteUrl, locale },
{
url,
name: title,
description,
imageUrl: ogImageURL.href,
type: pageType,
crumbs: breadcrumbs,
mainEntityId,
},
...(extraJsonLd ?? []),
)
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org',
logo: 'https://comfy.org/icons/logomark.svg',
sameAs: [
'https://github.com/comfyanonymous/ComfyUI',
'https://discord.gg/comfyorg',
'https://x.com/comaboratory',
'https://reddit.com/r/comfyui',
'https://linkedin.com/company/comfyorg',
'https://instagram.com/comfyorg',
],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Comfy',
url: 'https://comfy.org',
}
---
<!doctype html>
@@ -126,7 +100,10 @@ const structuredData = noindex
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
{structuredData && <JsonLdGraph graph={structuredData} />}
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
<slot name="head" />
<!-- Google Tag Manager -->
@@ -146,25 +123,7 @@ const structuredData = noindex
)}
<ClientRouter />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (
<script
is:inline
define:vars={{
bannerVersion,
storageKey: BANNER_STORAGE_KEY,
dismissAttr: BANNER_DISMISS_ATTR
}}
>
try {
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
if (dismissed[bannerVersion]) {
document.documentElement.setAttribute(dismissAttr, '')
}
} catch (e) {}
</script>
)}
<slot name="head" />
</head>
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
{gtmEnabled && (
@@ -178,16 +137,8 @@ const structuredData = noindex
</noscript>
)}
{bannerVisible && (
<AnnouncementBanner
data={bannerData}
version={bannerVersion}
locale={locale}
client:load
/>
)}
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<main>
<main class="mt-20 lg:mt-32">
<slot />
</main>
<SiteFooter locale={locale} client:load />

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout
title="404 - Page Not Found - Comfy"
title="404 Page Not Found Comfy"
noindex>
<div
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">

View File

@@ -5,25 +5,9 @@ import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="About Us - Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.about', locale) },
]}
>
<BaseLayout title="About Us — Comfy">
<HeroSection client:load />
<StorySection />
<OurValuesSection />

View File

@@ -9,36 +9,34 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
const pageTitle = t('affiliate.page.title', 'en')
const pageDescription = t('affiliate.page.description', 'en')
const { locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const faqPage: JsonLdNode = {
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
})),
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
}
---
<BaseLayout
title={pageTitle}
description={pageDescription}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: pageTitle },
]}
extraJsonLd={[faqPage]}
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection />
<HowItWorksSection />

View File

@@ -1,54 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { externalLinks, getRoutes } from '../config/routes'
const routes = getRoutes('en')
const resources = [
{
label: 'Learning Center',
href: routes.learning,
display: 'comfy.org/learning'
},
{
label: 'Workflow templates',
href: externalLinks.workflows,
display: 'comfy.org/workflows'
},
{
label: 'Customer stories',
href: routes.customers,
display: 'comfy.org/customers'
},
{
label: 'Docs',
href: externalLinks.docs,
display: 'docs.comfy.org'
}
]
---
<BaseLayout
title="You're booked - Comfy"
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
noindex
>
<HeroSection title="You're booked." class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
>
<p>Check your email for the calendar invite and meeting link!</p>
<div class="flex flex-col gap-4">
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
Resources while you wait
</h2>
<ResourceList resources={resources} />
</div>
</div>
</section>
</BaseLayout>

View File

@@ -1,28 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import BrandBackground from '../templates/brand/BrandBackground.vue'
import BrandHeroSection from '../templates/brand/BrandHeroSection.vue'
import BrandLogosSection from '../templates/brand/BrandLogosSection.vue'
import BrandColorSection from '../templates/brand/BrandColorSection.vue'
import BrandVoiceSection from '../templates/brand/BrandVoiceSection.vue'
import BrandTrademarkSection from '../templates/brand/BrandTrademarkSection.vue'
import BrandQuestionsSection from '../templates/brand/BrandQuestionsSection.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
---
<BaseLayout
title={t('brand.page.title', locale)}
description={t('brand.page.description', locale)}
>
<div class="relative">
<BrandBackground client:idle />
<BrandHeroSection />
<BrandLogosSection />
<BrandColorSection client:visible />
<BrandVoiceSection />
<BrandTrademarkSection />
<BrandQuestionsSection />
</div>
</BaseLayout>

View File

@@ -7,13 +7,6 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
import { t } from '../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -26,31 +19,11 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="Careers - Comfy"
title="Careers Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection />
<RolesSection departments={departments} client:visible />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Case Studies - Comfy">
<BaseLayout title="Case Studies Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud - AI in the Cloud"
title="Comfy Cloud AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>

View File

@@ -2,41 +2,9 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="Pricing - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<BaseLayout title="Pricing — Comfy Cloud">
<PriceSection client:load />
<WhatsIncludedSection />
</BaseLayout>

View File

@@ -4,44 +4,39 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../i18n/translations'
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const title = t('cloudNodes.meta.title', 'en')
const description = t('cloudNodes.meta.description', 'en')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Custom-node packs supported on Comfy Cloud',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
})),
)
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,12 +7,7 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -34,45 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
description: pack.description || undefined,
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
authorName: pack.publisher?.name,
isFree: true,
})
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -2,25 +2,9 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import FormSection from '../components/contact/FormSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Contact - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.contact', locale) },
]}
>
<BaseLayout title="Contact — Comfy">
<FormSection client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../utils/loadStories'
const stories = (await loadStories('en')).map(toCardProps)
---
<BaseLayout title="Customer Stories - Comfy">
<BaseLayout title="Customer Stories Comfy">
<HeroSection client:load />
<StorySection stories={stories} />
<FeedbackSection client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} - Comfy`}>
<BaseLayout title={`${entry.data.title} Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -7,13 +7,6 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -26,55 +19,94 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="Demos - Comfy" description="Interactive demos and tutorials for ComfyUI.">
<BaseLayout title="Demos Comfy" description="Interactive demos and tutorials for ComfyUI.">
<ComingSoon />
</BaseLayout>

View File

@@ -8,30 +8,11 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Download Comfy Desktop - Run AI on Your Hardware"
title="Download Comfy Desktop Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -1,36 +0,0 @@
---
// Enterprise Customer Agreement (Enterprise MSA) — English only, by design.
// Legal-reviewed copy must not be served under a localized route until legal
// explicitly approves a translation; rendering an unreviewed translation as
// the active MSA exposes us to liability from the translation diverging from
// the approved English source. See the matching comment in
// src/i18n/translations.ts for the i18n block, and the entry in
// LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/legal/HeroSection.vue'
import LegalContentSection from '../components/legal/LegalContentSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('enterprise-msa.page.title')}
description={t('enterprise-msa.page.description')}
>
<HeroSection title={t('enterprise-msa.page.heading')} />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('enterprise-msa.page.effectiveDateLabel')}: {
t('enterprise-msa.effective-date')
}
</p>
<p
class="text-primary-comfy-canvas mx-auto mt-8 max-w-3xl px-4 text-center text-sm/relaxed lg:px-0"
>
{t('enterprise-msa.page.parties')}
</p>
<LegalContentSection
prefix="enterprise-msa"
locale="en"
tocLabelKey="enterprise-msa.page.tocLabel"
client:load
/>
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
---
<BaseLayout title="Gallery - Comfy">
<BaseLayout title="Gallery Comfy">
<HeroSection />
<GallerySection client:load />
<ContactSection />

View File

@@ -9,28 +9,11 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from "../utils/jsonLd";
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
);
---
<BaseLayout
title="Comfy - Professional Control of Visual AI"
title="Comfy Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[
comfyUiApplicationNode(siteUrl),
comfyUiSourceCodeNode(siteUrl),
]}
keywords={[
"comfyui app",
"comfyui web app",

View File

@@ -1,60 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { getRoutes } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout
title="Thanks for reaching out - Comfy"
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
noindex
>
<HeroSection title="Thanks for reaching out." class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
>
<p>
Based on what you shared, one of our self-serve plans is probably a
better fit.
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>,
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>, and
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
> for individual creators, plus our new
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> plan for multiple users under shared billing.
</p>
<PlansPricingCta
href={routes.cloudPricing}
label="See plans and pricing →"
/>
<p class="text-primary-warm-gray mt-8 text-sm">
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
href="mailto:gtm-team@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>gtm-team@comfy.org</a
>
</p>
<p class="text-primary-warm-gray text-sm">
Need help with something else? Email: <a
href="mailto:support@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>support@comfy.org</a
>
</p>
</div>
</section>
</BaseLayout>

View File

@@ -3,6 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
@@ -12,6 +13,7 @@ const locale = 'en' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -12,7 +12,7 @@ import { externalLinks } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout title="Learning - Comfy">
<BaseLayout title="Learning Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
---
<BaseLayout
title="Models - Comfy"
title="Models Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection

View File

@@ -4,13 +4,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
@@ -26,6 +19,7 @@ if (model.canonicalSlug) {
}
const { displayName } = model
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
const dirDescriptions: Record<string, string> = {
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
@@ -46,31 +40,55 @@ const dirDescriptions: Record<string, string> = {
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: displayName,
url,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Any',
})
const faqPage: JsonLdNode = {
url: canonicalURL.href,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('models.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('models.breadcrumb.models'),
item: 'https://comfy.org/p/supported-models'
},
{
'@type': 'ListItem',
position: 3,
name: displayName
}
]
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
},
{
'@type': 'Question',
@@ -79,44 +97,54 @@ const faqPage: JsonLdNode = {
'@type': 'Answer',
text: model.docsUrl
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
},
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
}
},
{
'@type': 'Question',
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
},
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
}
},
{
'@type': 'Question',
name: `Is ${displayName} free to use in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
},
},
],
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
}
}
]
}
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
---
<BaseLayout
title={`${pageTitle} - Comfy`}
title={`${pageTitle} Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('models.breadcrumb.models', locale),
url: absoluteUrl(Astro.site, '/p/supported-models'),
},
{ name: displayName },
]}
extraJsonLd={[software, faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(softwareAppJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<ModelHeroSection
displayName={displayName}

View File

@@ -2,29 +2,10 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const modelList = itemListNode(
url,
title,
models.map((model) => ({
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
})),
)
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
@@ -43,15 +24,8 @@ const dirLabel: Record<string, string> = {
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: title },
]}
extraJsonLd={[modelList]}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Failed - Comfy"
title="Payment Failed Comfy"
description="Your payment was not completed."
noindex
>

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Successful - Comfy"
title="Payment Successful Comfy"
description="Your payment was processed successfully."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../components/legal/HeroSection.vue'
---
<BaseLayout
title="Privacy Policy - Comfy"
title="Privacy Policy Comfy"
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
noindex
>

Some files were not shown because too many files have changed in this diff Show More