diff --git a/.github/actions/deploy-env/action.yml b/.github/actions/deploy-env/action.yml index 8b625cdc7..30d387bcd 100644 --- a/.github/actions/deploy-env/action.yml +++ b/.github/actions/deploy-env/action.yml @@ -1,8 +1,8 @@ name: deploy setup description: >- - Toolchain and dependencies for an alchemy deploy job. Secrets come from the job's GitHub - Environment (`production` or `pr-preview`) as env vars; alchemy reads them through Config - when it plans the stack (see apps/*/alchemy.run.ts and apps/backend/src/worker/env.ts). + Toolchain and dependencies for an alchemy deploy job. Secrets reach the job as env vars + (prd: Infisical's `prod` environment; previews: the `pr-preview` GitHub Environment); alchemy + reads them through Config when it plans the stack (see apps/*/alchemy.run.ts and apps/backend/src/worker/env.ts). runs: using: composite diff --git a/.github/workflows/deploy-prd.yml b/.github/workflows/deploy-prd.yml index 030255c10..6f41c7311 100644 --- a/.github/workflows/deploy-prd.yml +++ b/.github/workflows/deploy-prd.yml @@ -14,6 +14,8 @@ concurrency: permissions: contents: read + # The Infisical step signs in with this job's GitHub OIDC token. + id-token: write jobs: deploy: @@ -31,57 +33,26 @@ jobs: env: # On workflow_run, `github.sha` is the branch head, not the commit CI tested. COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - # api Worker (apps/backend/src/worker/env.ts) - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - # Hazel sign-in - AUTH_GITHUB_CLIENT_ID: ${{ vars.AUTH_GITHUB_CLIENT_ID || secrets.AUTH_GITHUB_CLIENT_ID }} - AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.AUTH_GITHUB_CLIENT_SECRET }} - AUTH_GOOGLE_CLIENT_ID: ${{ vars.AUTH_GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_CLIENT_ID }} - AUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.AUTH_GOOGLE_CLIENT_SECRET }} - AUTH_BINDING_KEY: ${{ secrets.AUTH_BINDING_KEY }} - AUTH_TRANSACTION_KEY: ${{ secrets.AUTH_TRANSACTION_KEY }} - AUTH_ACCESS_TOKEN_KID: ${{ vars.AUTH_ACCESS_TOKEN_KID || secrets.AUTH_ACCESS_TOKEN_KID }} - AUTH_ACCESS_TOKEN_PRIVATE_JWK: ${{ secrets.AUTH_ACCESS_TOKEN_PRIVATE_JWK }} - AUTH_PASSKEY_RP_ID: ${{ vars.AUTH_PASSKEY_RP_ID }} - AUTH_TRUSTED_ORIGINS: ${{ vars.AUTH_TRUSTED_ORIGINS }} - COOKIE_DOMAIN: ${{ vars.COOKIE_DOMAIN }} - S3_BUCKET: ${{ vars.S3_BUCKET }} - S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} - S3_PUBLIC_URL: ${{ vars.S3_PUBLIC_URL }} - S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} - S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} - INTEGRATION_ENCRYPTION_KEY: ${{ secrets.INTEGRATION_ENCRYPTION_KEY }} - INTEGRATION_ENCRYPTION_KEY_VERSION: ${{ vars.INTEGRATION_ENCRYPTION_KEY_VERSION }} - INTEGRATION_ENCRYPTION_KEY_PREV: ${{ secrets.INTEGRATION_ENCRYPTION_KEY_PREV }} - INTEGRATION_ENCRYPTION_KEY_VERSION_PREV: ${{ vars.INTEGRATION_ENCRYPTION_KEY_VERSION_PREV }} - LINEAR_CLIENT_ID: ${{ vars.LINEAR_CLIENT_ID }} - LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} - DISCORD_CLIENT_ID: ${{ vars.DISCORD_CLIENT_ID }} - DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} - DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} - DISCORD_GATEWAY_ENABLED: ${{ vars.DISCORD_GATEWAY_ENABLED }} - GITHUB_APP_ID: ${{ vars.HAZEL_GITHUB_APP_ID }} - GITHUB_APP_SLUG: ${{ vars.HAZEL_GITHUB_APP_SLUG }} - GITHUB_APP_PRIVATE_KEY: ${{ secrets.HAZEL_GITHUB_APP_PRIVATE_KEY }} - GITHUB_WEBHOOK_SECRET: ${{ secrets.HAZEL_GITHUB_WEBHOOK_SECRET }} - INTERNAL_SECRET: ${{ secrets.INTERNAL_SECRET }} - KLIPY_API_KEY: ${{ secrets.KLIPY_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - # First-party bots (bots/*/src/worker.ts): each deploys only when its token is set - HAZEL_BOT_TOKEN: ${{ secrets.HAZEL_BOT_TOKEN }} - LINEAR_BOT_TOKEN: ${{ secrets.LINEAR_BOT_TOKEN }} - # Maple: an org-admin API key with `ingest_keys:read`, `dashboards:write`, `alerts:write`; the deploy binds the ingest key onto the Workers - MAPLE_API_KEY: ${{ secrets.MAPLE_API_KEY }} - # A Hazel channel's Maple webhook URL: production alerts post there (`@hazel/infra/maple-monitoring`) - MAPLE_ALERTS_WEBHOOK_URL: ${{ secrets.MAPLE_ALERTS_WEBHOOK_URL }} + # Every deploy secret (Cloudflare, the api Worker's env, bots, Maple) comes from + # Infisical's `prod` environment, the same source `alchemy:deploy:prd:local` reads. + # Don't list them here: a job-level `env:` entry shadows what the Infisical step exports. steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{ env.COMMIT_SHA }} + - name: Load secrets from Infisical + uses: Infisical/secrets-action@v1.0.18 + with: + # A machine identity with OIDC Auth (issuer https://token.actions.githubusercontent.com) + # and read access to `prod`. The repo uses immutable OIDC subjects, so it is bound to + # repo:MapleTechLabs@282671153/hazel@972339714:environment:production. + method: oidc + identity-id: ${{ vars.INFISICAL_IDENTITY_ID }} + project-id: 9e94bb4d-0348-440c-b0f3-073b6f1977f3 + env-slug: prod + - name: Deploy setup uses: ./.github/actions/deploy-env diff --git a/apps/web-foldkit/package.json b/apps/web-foldkit/package.json index 7f1d42bf8..44de38f93 100644 --- a/apps/web-foldkit/package.json +++ b/apps/web-foldkit/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:browser": "vitest run --config vitest.browser.config.ts", + "gallery": "vitest run --config vitest.gallery.config.ts", "build:desktop": "./scripts/build-desktop.sh" }, "dependencies": { diff --git a/apps/web-foldkit/src/core/components/ui/button.styles.ts b/apps/web-foldkit/src/core/components/ui/button.styles.ts index f287d0d89..74bca7f7c 100644 --- a/apps/web-foldkit/src/core/components/ui/button.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/button.styles.ts @@ -3,7 +3,7 @@ import { tv } from "tailwind-variants" export const buttonStyles = tv({ base: [ "[--btn-border:var(--color-fg)]/15 [--btn-icon-active:var(--btn-fg)] [--btn-outline:var(--btn-bg)] [--btn-radius:calc(var(--radius-lg)-1px)] [--btn-ring:var(--btn-bg)]/20", - "bg-(--btn-bg) text-(--btn-fg) outline-(--btn-outline) ring-(--btn-ring) not-disabled:not-data-pending:hover:bg-(--btn-overlay)", + "bg-(--btn-bg) text-(--btn-fg) outline-(--btn-outline) ring-(--btn-ring) not-disabled:not-data-pending:hover:bg-(--btn-overlay) not-disabled:not-data-pending:active:bg-(--btn-overlay)", "relative isolate inline-flex items-center justify-center border border-(--btn-border) font-medium hover:no-underline", "focus:outline-0 focus-visible:outline focus-visible:outline-offset-2 focus-visible:ring-2 focus-visible:ring-offset-3 focus-visible:ring-offset-bg", "*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) focus-visible:*:data-[slot=icon]:text-(--btn-icon-active)/80 not-disabled:not-data-pending:hover:*:data-[slot=icon]:text-(--btn-icon-active)/90 forced-colors:[--btn-icon:ButtonText] forced-colors:hover:[--btn-icon:ButtonText]", @@ -14,12 +14,12 @@ export const buttonStyles = tv({ variants: { intent: { primary: - "[--btn-bg:var(--color-primary)] [--btn-fg:var(--color-primary-fg)] [--btn-icon-active:var(--primary-fg)]/80 [--btn-icon:var(--primary-fg)]/60 [--btn-overlay:color-mix(in_oklab,var(--color-primary-fg)_10%,var(--color-primary)_90%)]", + "shadow-control [--btn-border:transparent] [--btn-bg:var(--color-primary)] [--btn-fg:var(--color-primary-fg)] [--btn-icon-active:var(--primary-fg)]/80 [--btn-icon:var(--primary-fg)]/60 [--btn-overlay:color-mix(in_oklab,var(--color-primary-fg)_10%,var(--color-primary)_90%)]", secondary: - "[--btn-bg:var(--color-secondary)] [--btn-fg:var(--color-secondary-fg)] [--btn-icon:var(--color-muted-fg)] [--btn-outline:var(--color-secondary-fg)] [--btn-overlay:color-mix(in_oklab,var(--color-secondary-fg)_10%,var(--color-secondary)_90%)] [--btn-ring:var(--color-muted-fg)]/20", + "[--btn-border:transparent] [--btn-bg:var(--color-secondary)] [--btn-fg:var(--color-secondary-fg)] [--btn-icon:var(--color-muted-fg)] [--btn-outline:var(--color-secondary-fg)] [--btn-overlay:color-mix(in_oklab,var(--color-secondary-fg)_10%,var(--color-secondary)_90%)] [--btn-ring:var(--color-muted-fg)]/20", warning: "[--btn-bg:var(--color-warning)] [--btn-fg:var(--color-warning-fg)] [--btn-icon:var(--color-warning-fg)]/60 [--btn-overlay:color-mix(in_oklab,var(--color-white)_10%,var(--color-warning)_90%)]", - danger: "[--btn-bg:var(--color-danger)] [--btn-fg:var(--color-danger-fg)] [--btn-icon:color-mix(in_oklab,var(--color-danger-fg)_60%,var(--danger)_40%)] [--btn-overlay:color-mix(in_oklab,var(--color-white)_10%,var(--color-danger)_90%)]", + danger: "shadow-control [--btn-border:transparent] [--btn-bg:var(--color-danger)] [--btn-fg:var(--color-danger-fg)] [--btn-icon:color-mix(in_oklab,var(--color-danger-fg)_60%,var(--danger)_40%)] [--btn-overlay:color-mix(in_oklab,var(--color-white)_10%,var(--color-danger)_90%)]", outline: "border-border [--btn-bg:transparent] [--btn-icon:var(--color-muted-fg)] [--btn-outline:var(--color-ring)] [--btn-overlay:var(--color-secondary)] [--btn-ring:var(--color-ring)]/20", plain: "border-transparent [--btn-bg:transparent] [--btn-icon:var(--color-muted-fg)] [--btn-outline:var(--color-ring)] [--btn-overlay:var(--color-secondary)] [--btn-ring:var(--color-ring)]/20", diff --git a/apps/web-foldkit/src/core/components/ui/card.styles.ts b/apps/web-foldkit/src/core/components/ui/card.styles.ts index 0c1b28b32..fe545fa3e 100644 --- a/apps/web-foldkit/src/core/components/ui/card.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/card.styles.ts @@ -1,10 +1,10 @@ export const cardStyles = { - card: "overflow-hidden rounded-xl border shadow-sm", + card: "overflow-hidden rounded-xl border", cardDefault: "border-border bg-bg", cardDanger: "border-danger/20 bg-danger/5", - cardHeader: "border-border border-b bg-bg-muted/30 px-4 py-5 md:px-6", + cardHeader: "flex flex-col gap-0.5 border-border border-b px-4 py-4 md:px-6", cardHeaderGroup: "flex flex-col items-start gap-4 md:flex-row", cardBody: "p-4 md:p-6", - cardTitle: "font-semibold text-2xl leading-none tracking-tight", - cardDescription: "text-muted-fg text-sm", + cardTitle: "font-medium text-base/6 text-fg", + cardDescription: "text-fg-2 text-sm", } as const diff --git a/apps/web-foldkit/src/core/components/ui/date-field.styles.ts b/apps/web-foldkit/src/core/components/ui/date-field.styles.ts index be0df86a7..f158e10ba 100644 --- a/apps/web-foldkit/src/core/components/ui/date-field.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/date-field.styles.ts @@ -1,7 +1,7 @@ export const dateInputStyles = [ "relative block appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]", "text-base/6 text-fg placeholder:text-muted-fg sm:text-sm/6", - "border border-input enabled:hover:border-muted-fg/30", + "border border-transparent bg-field enabled:hover:bg-field-hover", "focus-within:border-ring/70 focus-within:outline-hidden focus-within:ring-3 focus-within:ring-ring/20 focus-within:hover:border-ring/80", "group-data-open:border-ring/70 group-data-open:outline-hidden group-data-open:ring-3 group-data-open:ring-ring/20 group-data-open:enabled:hover:border-ring/80", "data-invalid:border-danger-subtle-fg/70 focus-within:data-invalid:border-danger-subtle-fg/70 focus-within:data-invalid:ring-danger-subtle-fg/20 focus-within:data-invalid:hover:border-danger-subtle-fg/80 data-invalid:enabled:hover:border-danger-subtle-fg/80", diff --git a/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts b/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts index b9a92e926..696194ae5 100644 --- a/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts @@ -19,7 +19,7 @@ export const dropdownItemStyles = tv({ // avatar "*:data-[slot=avatar]:mr-(--mr-icon) has-[[slot=description]]:*:data-[slot=avatar]:row-span-2 *:data-[slot=avatar]:[--avatar-size:--spacing(6)] sm:*:data-[slot=avatar]:[--avatar-size:--spacing(5)]", // icon - "*:data-[slot=icon]:col-start-1 *:data-[slot=icon]:row-start-1 *:data-[slot=icon]:mr-(--mr-icon) *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:shrink-0 [&_[data-slot='icon']:not([class*='text-'])]:text-muted-fg", + "*:data-[slot=icon]:col-start-1 *:data-[slot=icon]:row-start-1 *:data-[slot=icon]:mr-(--mr-icon) *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:shrink-0 [&_[data-slot='icon']:not([class*='text-'])]:text-fg-2", "not-has-[[slot=description]]:*:data-[slot=icon]:size-5 sm:not-has-[[slot=description]]:*:data-[slot=icon]:size-4", "has-[[slot=description]]:*:data-[slot=icon]:h-lh has-[[slot=description]]:[&_[data-slot='icon']:not([class*='w-'])]:w-5 sm:has-[[slot=description]]:[&_[data-slot='icon']:not([class*='w-'])]:w-4", "[&>[slot=label]+[data-slot=icon]]:absolute [&>[slot=label]+[data-slot=icon]]:right-1", @@ -46,22 +46,22 @@ export const dropdownItemStyles = tv({ }, isSelected: { true: [ - "[&_[data-slot='icon']:not([class*='text-'])]:text-accent-fg", + "[&_[data-slot='icon']:not([class*='text-'])]:text-fg", "has-data-[slot=avatar]:pr-6 has-data-[slot=icon]:pr-6", ], }, isFocused: { true: [ - "*:data-[slot=keyboard]:text-accent-fg [&_[data-slot='icon']:not([class*='text-'])]:text-accent-fg", - "bg-accent text-accent-fg forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", - "[&_.text-muted-fg]:text-accent-fg/80 *:[[slot=description]]:text-accent-fg *:[[slot=label]]:text-accent-fg", + "*:data-[slot=keyboard]:text-fg-2 [&_[data-slot='icon']:not([class*='text-'])]:text-fg-3", + "bg-wash-strong text-fg forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", + "[&_.text-muted-fg]:text-fg-2 *:[[slot=description]]:text-fg-2 *:[[slot=label]]:text-fg", ], }, isHovered: { true: [ - "*:data-[slot=keyboard]:text-accent-fg [&_[data-slot='icon']:not([class*='text-'])]:text-accent-fg", - "bg-accent text-accent-fg forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", - "[&_.text-muted-fg]:text-accent-fg/80 *:[[slot=description]]:text-accent-fg *:[[slot=label]]:text-accent-fg", + "*:data-[slot=keyboard]:text-fg-2 [&_[data-slot='icon']:not([class*='text-'])]:text-fg-3", + "bg-wash-strong text-fg forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", + "[&_.text-muted-fg]:text-fg-2 *:[[slot=description]]:text-fg-2 *:[[slot=label]]:text-fg", ], }, }, @@ -77,6 +77,6 @@ export const dropdownCheckIndicatorClassName = twJoin( /** Base classes, each merged with the caller's `className` by `twMerge`. */ export const dropdownLabelBase = "col-start-2" export const dropdownDescriptionBase = "col-start-2 font-normal text-muted-fg text-sm" -export const dropdownSeparatorBase = "col-span-full -mx-1 h-px bg-fg/10" +export const dropdownSeparatorBase = "col-span-full -mx-1 my-1 h-px bg-border" export const dropdownKeyboardBase = "absolute right-2 pl-2 group-hover:text-primary-fg group-data-focused:text-primary-fg" diff --git a/apps/web-foldkit/src/core/components/ui/input.styles.ts b/apps/web-foldkit/src/core/components/ui/input.styles.ts index 1a8e8b7a4..fb882ab27 100644 --- a/apps/web-foldkit/src/core/components/ui/input.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/input.styles.ts @@ -2,7 +2,7 @@ export const inputStyles = [ "relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]", "text-base/6 text-fg placeholder:text-muted-fg sm:text-sm/6", - "border border-input enabled:hover:border-muted-fg/30", + "border border-transparent bg-field enabled:hover:bg-field-hover", "outline-hidden focus:border-ring/70 focus:ring-3 focus:ring-ring/20 focus:enabled:hover:border-ring/80", "data-invalid:border-danger-subtle-fg/70 focus:data-invalid:border-danger-subtle-fg/70 focus:data-invalid:ring-danger-subtle-fg/20 data-invalid:enabled:hover:border-danger-subtle-fg/80 focus:data-invalid:enabled:hover:border-danger-subtle-fg/80", "[&::-ms-reveal]:hidden [&::-webkit-search-cancel-button]:hidden", diff --git a/apps/web-foldkit/src/core/components/ui/menu.styles.ts b/apps/web-foldkit/src/core/components/ui/menu.styles.ts index 3c646422b..95c8aa9b8 100644 --- a/apps/web-foldkit/src/core/components/ui/menu.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/menu.styles.ts @@ -16,7 +16,7 @@ export const menuPopoverBase = "min-w-32" export const menuItemSubmenuOpen = { danger: "data-open:bg-danger-subtle data-open:text-danger-subtle-fg", warning: "data-open:bg-warning-subtle data-open:text-warning-subtle-fg", - none: "data-open:bg-accent data-open:text-accent-fg data-open:*:data-[slot=icon]:text-accent-fg data-open:*:[.text-muted-fg]:text-accent-fg", + none: "data-open:bg-wash-strong data-open:text-fg data-open:*:data-[slot=icon]:text-fg-3 data-open:*:[.text-muted-fg]:text-fg-2", } as const export const menuChevronClassName = "absolute right-2 size-3.5" diff --git a/apps/web-foldkit/src/core/components/ui/select.styles.ts b/apps/web-foldkit/src/core/components/ui/select.styles.ts index 631130b0b..00ccb0262 100644 --- a/apps/web-foldkit/src/core/components/ui/select.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/select.styles.ts @@ -9,9 +9,9 @@ export const selectTriggerWrapperClassName = "relative block w-full" /** The `cx` arguments of SelectTrigger's button, before the caller's `className`. */ export const selectTriggerBase = [ - "group/select-trigger flex w-full min-w-0 cursor-default items-center gap-x-2 rounded-lg border border-input px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] text-start text-fg outline-hidden transition duration-200 sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6 sm:*:text-sm/6 dark:shadow-none", + "group/select-trigger flex w-full min-w-0 cursor-default items-center gap-x-2 rounded-lg border border-transparent bg-field px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] text-start text-fg outline-hidden sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6 sm:*:text-sm/6 dark:shadow-none", "focus:border-ring/70 focus:bg-primary-subtle/5 focus:ring-3 focus:ring-ring/20 focus:enabled:hover:border-ring/80", - "enabled:hover:border-muted-fg/30", + "enabled:hover:bg-field-hover", "group-data-open/select:border-ring/70 group-data-open/select:bg-primary-subtle/5 group-data-open/select:ring-3 group-data-open/select:ring-ring/20", "group-data-open/select:data-invalid:border-danger-subtle-fg/70 group-data-open/select:data-invalid:bg-danger-subtle/5 group-data-open/select:data-invalid:ring-3 group-data-open/select:data-invalid:ring-danger-subtle-fg/20 group-data-invalid/select:border-danger-subtle-fg/70 group-data-invalid/select:bg-danger-subtle/5 group-data-invalid/select:ring-danger-subtle-fg/20 group-data-invalid/select:enabled:hover:border-danger-subtle-fg/80 group-data-focused/select:group-data-invalid/select:border-danger-subtle-fg/70 group-data-focused/select:group-data-invalid/select:ring-danger-subtle-fg/20 group-data-focused/select:group-data-invalid/select:enabled:hover:border-danger-subtle-fg/80", "*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) data-pressed:*:data-[slot=icon]:text-(--btn-icon-active) focus-visible:*:data-[slot=icon]:text-(--btn-icon-active)/80 enabled:hover:*:data-[slot=icon]:text-(--btn-icon-active)/90 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:hover:[--btn-icon:ButtonText]", diff --git a/apps/web-foldkit/src/core/components/ui/textarea.styles.ts b/apps/web-foldkit/src/core/components/ui/textarea.styles.ts index 047ecb61f..5b6161501 100644 --- a/apps/web-foldkit/src/core/components/ui/textarea.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/textarea.styles.ts @@ -2,7 +2,7 @@ export const textareaStyles = [ "field-sizing-content relative block min-h-16 w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]", "text-base/6 text-fg placeholder:text-muted-fg sm:text-sm/6", - "border border-input enabled:hover:border-muted-fg/30", + "border border-transparent bg-field enabled:hover:bg-field-hover", "outline-hidden focus:border-ring/70 focus:ring-3 focus:ring-ring/20 focus:enabled:hover:border-ring/80", "data-invalid:border-danger-subtle-fg/70 focus:data-invalid:border-danger-subtle-fg/70 focus:data-invalid:ring-danger-subtle-fg/20 data-invalid:enabled:hover:border-danger-subtle-fg/80 data-invalid:focus:enabled:hover:border-danger-subtle-fg/80", "data-disabled:bg-muted forced-colors:in-data-disabled:text-[GrayText]", diff --git a/apps/web-foldkit/src/core/lib/theme/apply.ts b/apps/web-foldkit/src/core/lib/theme/apply.ts index 03ec9380c..929e05863 100644 --- a/apps/web-foldkit/src/core/lib/theme/apply.ts +++ b/apps/web-foldkit/src/core/lib/theme/apply.ts @@ -21,7 +21,7 @@ const LIGHT_MODE_GRAY_TOKENS = { "--overlay-fg": "950", "--muted": "100", "--muted-fg": "600", - "--border": "300", + "--border": "200", "--input": "300", "--navbar": "25", "--navbar-fg": "950", diff --git a/apps/web-foldkit/src/core/styles/styles.css b/apps/web-foldkit/src/core/styles/styles.css index abae02d56..e8a2191bb 100644 --- a/apps/web-foldkit/src/core/styles/styles.css +++ b/apps/web-foldkit/src/core/styles/styles.css @@ -13,7 +13,7 @@ If you want to use the typography plugin, uncomment the line below. @theme inline { --font-sans: - var(--font-inter), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-mono: @@ -30,6 +30,17 @@ If you want to use the typography plugin, uncomment the line below. --color-bg: var(--bg); --color-fg: var(--fg); + --color-fg-1: var(--fg-1); + --color-fg-2: var(--fg-2); + --color-fg-3: var(--fg-3); + --color-fg-4: var(--fg-4); + + --color-wash: var(--wash); + --color-wash-strong: var(--wash-strong); + --color-field: var(--field); + --color-field-hover: var(--field-hover); + + --shadow-control: var(--control-shadow); --color-primary: var(--primary); --color-primary-fg: var(--primary-fg); @@ -291,6 +302,26 @@ If you want to use the typography plugin, uncomment the line below. --bg: var(--color-white); --fg: var(--color-zinc-950); + /* + * The text ladder, mixed from --fg so every gray palette gets its own: fg-4 titles, values and + * typed text; fg-3 body and rows at rest; fg-2 meta, hints and placeholders; fg-1 never readable + * text, only dividers and disabled marks. An icon draws one step under its text. + */ + --fg-4: var(--fg); + --fg-3: color-mix(in oklab, var(--fg) 72%, var(--bg)); + --fg-2: color-mix(in oklab, var(--fg) 56%, var(--bg)); + --fg-1: color-mix(in oklab, var(--fg) 28%, var(--bg)); + + /* Alpha washes, the same on any surface: wash under a hovered row, wash-strong under the current + row; field is a filled input's surface (fields have a fill, not a border). */ + --wash: oklch(0 0 0 / 0.035); + --wash-strong: oklch(0 0 0 / 0.06); + --field: oklch(0 0 0 / 0.04); + --field-hover: oklch(0 0 0 / 0.065); + + /* A filled control's edge: a top highlight and a bottom shade instead of a border. */ + --control-shadow: inset 0 1px 0 oklch(1 0 0 / 0.16), inset 0 -1px 0 oklch(0 0 0 / 0.12); + --primary: var(--color-brand-600); --primary-fg: oklch(1 0 0); --primary-subtle: rgb(from var(--color-brand-500) r g b / 15%); @@ -326,7 +357,7 @@ If you want to use the typography plugin, uncomment the line below. --danger-subtle: theme("color-red-500/15%"); --danger-subtle-fg: var(--color-red-700); - --border: var(--color-zinc-300); + --border: var(--color-zinc-200); --input: var(--color-zinc-300); --ring: var(--primary); @@ -376,6 +407,12 @@ If you want to use the typography plugin, uncomment the line below. --bg: oklch(0.19 0.006 285.885); --fg: var(--color-zinc-50); + --wash: oklch(1 0 0 / 0.045); + --wash-strong: oklch(1 0 0 / 0.08); + --field: oklch(1 0 0 / 0.05); + --field-hover: oklch(1 0 0 / 0.08); + --control-shadow: inset 0 1px 0 oklch(1 0 0 / 0.14), inset 0 -1px 0 oklch(0 0 0 / 0.2); + --primary: var(--color-brand-600); --primary-fg: oklch(1 0 0); --primary-subtle: rgb(from var(--color-brand-500) r g b / 15%); @@ -447,6 +484,12 @@ If you want to use the typography plugin, uncomment the line below. } } +/* An active label's weight without the width: it keeps 400 and gains a hairline stroke, so nothing + beside it moves (`data-current:faux-medium`). */ +@utility faux-medium { + -webkit-text-stroke: 0.011em currentColor; +} + /* Tabular numbers for dynamic content (counters, timestamps, prices) */ @utility tabular-nums { font-variant-numeric: tabular-nums; diff --git a/apps/web-foldkit/src/design.gallery.ts b/apps/web-foldkit/src/design.gallery.ts new file mode 100644 index 000000000..c6bb1e102 --- /dev/null +++ b/apps/web-foldkit/src/design.gallery.ts @@ -0,0 +1,218 @@ +import "@fontsource/inter/400.css" +import "@fontsource/inter/400-italic.css" +import "@fontsource/inter/500.css" +import "@fontsource/inter/600.css" +import "./styles.css" +import type { ChannelId } from "@hazel/schema" +import { Context } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { MountTracker } from "foldkit/mount" +import { __clearRuntime, __htmlBuilder, __setRuntime } from "foldkit-internal/html/index.js" +import { Dispatch } from "foldkit-internal/runtime/dispatch.js" +import { patch } from "foldkit-internal/vdom.js" +import { twMerge } from "tailwind-merge" +import { afterEach, test } from "vitest" +import { page, userEvent } from "vitest/browser" +import { dropdownItemStyles, dropdownLabelBase, dropdownSeparatorBase } from "~/components/ui/dropdown.styles" +import { menuContentStyles, menuPopoverBase } from "~/components/ui/menu.styles" +import { popoverContentBase } from "~/components/ui/popover.styles" +import { IconBell, IconCopy, IconPlus, IconStar, IconTrash, IconUsers } from "./icons" +import type { ChannelEntry } from "./shell/channels-sidebar/rows" +import { channelItem } from "./shell/channels-sidebar/items" +import { sectionGroupHeader, sectionPlusButton } from "./shell/channels-sidebar/tree" +import { badge } from "./ui/badge" +import { button } from "./ui/button" +import { card, cardBody, cardDescription, cardHeader, cardTitle } from "./ui/card" +import { textField } from "./ui/text-field" + +/** + * The design gallery: a sidebar, a settings card, the button and badge sets and an open menu, built + * from the kit's real views and the app stylesheet, screenshotted in light and dark. Not a test: + * `VITE_GALLERY_LABEL=before bun run gallery` writes `src/before-light.png` and `src/before-dark.png`. + */ + +type M = never + +const render = (view: (h: HtmlBuilder) => Html) => { + const dispatch = Dispatch.of({ dispatchAsync: () => undefined as never, dispatchSync: () => undefined }) + __setRuntime( + dispatch.dispatchSync, + Context.make(Dispatch, dispatch).pipe( + Context.add(MountTracker, { started: () => {}, ended: () => {} }), + ), + ) + try { + const vnode = view(__htmlBuilder()) + const container = document.createElement("div") + document.body.append(container) + if (vnode) patch(container, vnode) + } finally { + __clearRuntime() + } +} + +afterEach(() => { + document.body.innerHTML = "" + document.documentElement.classList.remove("dark") +}) + +const entry = (id: string, name: string, notificationCount = 0): ChannelEntry => + ({ + channel: { id: id as ChannelId, name, type: "public", icon: null, sectionId: null }, + member: { id, isMuted: false, isFavorite: false, notificationCount }, + }) as unknown as ChannelEntry + +const channels: ReadonlyArray = [ + [entry("00000000-0000-4000-8000-000000000001", "general"), true], + [entry("00000000-0000-4000-8000-000000000002", "design", 3), false], + [entry("00000000-0000-4000-8000-000000000003", "engineering"), false], + [entry("00000000-0000-4000-8000-000000000004", "product-updates", 12), false], + [entry("00000000-0000-4000-8000-000000000005", "random"), false], +] + +const sidebar = (h: HtmlBuilder) => + h.aside( + [ + h.Class( + "flex w-60 shrink-0 flex-col gap-y-0.5 border-sidebar-border border-r bg-sidebar p-3 text-sidebar-fg", + ), + ], + [ + h.div( + [h.Class("grid grid-cols-[auto_1fr] gap-y-0.5")], + [ + sectionGroupHeader(h, { + name: "Channels", + isCollapsed: false, + action: sectionPlusButton(h, "Create channel", []), + }), + ...channels.map(([channel, isActive]) => + channelItem(h, channel, channel.member.notificationCount, [], { + orgSlug: "acme", + isActive, + }), + ), + ], + ), + ], + ) + +const menuItem = ( + h: HtmlBuilder, + icon: Html, + text: string, + options: { isFocused?: boolean; intent?: "danger"; keys?: string } = {}, +) => + h.div( + [ + h.Class(dropdownItemStyles({ isFocused: options.isFocused, intent: options.intent })), + h.Attribute("role", "menuitem"), + ...(options.isFocused ? [h.DataAttribute("focused", "true")] : []), + ], + [ + icon, + h.span([h.Class(twMerge(dropdownLabelBase)), h.Attribute("slot", "label")], [text]), + ...(options.keys ? [h.kbd([h.Class("col-start-5 text-muted-fg text-xs")], [options.keys])] : []), + ], + ) + +const menu = (h: HtmlBuilder) => + h.div( + [h.Class(twMerge(twMerge(...popoverContentBase), menuPopoverBase, "w-56"))], + [ + h.div( + [h.Class(menuContentStyles()), h.Attribute("role", "menu")], + [ + menuItem(h, IconBell(h), "Mute channel", { isFocused: true }), + menuItem(h, IconCopy(h), "Copy link"), + menuItem(h, IconStar(h), "Add to favorites"), + menuItem(h, IconUsers(h), "Members"), + h.div([h.Class(dropdownSeparatorBase), h.Attribute("role", "separator")], []), + menuItem(h, IconTrash(h), "Delete channel", { intent: "danger" }), + ], + ), + ], + ) + +const settings = (h: HtmlBuilder) => + card(h, {}, [ + cardHeader(h, [ + cardTitle(h, {}, ["Channel settings"]), + cardDescription(h, {}, ["Name, topic and who can join #general."]), + ]), + cardBody( + h, + [ + textField(h, { id: "name", value: "general" }, (parts) => [ + parts.label(["Name"]), + parts.input({}), + ]), + textField(h, { id: "topic", value: "" }, (parts) => [ + parts.label(["Topic"]), + parts.input({ placeholder: "What is this channel about?" }), + parts.description(["Shown under the channel name."]), + ]), + h.div( + [h.Class("flex justify-end gap-2")], + [ + button(h, { intent: "secondary", size: "sm" }, ["Keep it"]), + button(h, { intent: "primary", size: "sm" }, ["Save changes"]), + ], + ), + ], + "flex flex-col gap-4", + ), + ]) + +const controls = (h: HtmlBuilder) => + h.div( + [h.Class("flex flex-col gap-4")], + [ + h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + button(h, { intent: "primary", size: "sm" }, [IconPlus(h), "New channel"]), + button(h, { intent: "secondary", size: "sm" }, ["Invite people"]), + button(h, { intent: "outline", size: "sm" }, ["Export"]), + button(h, { intent: "plain", size: "sm" }, ["Archive"]), + button(h, { intent: "danger", size: "sm" }, ["Delete"]), + ], + ), + h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + badge(h, { intent: "primary" }, ["New"]), + badge(h, { intent: "secondary" }, ["Guest"]), + badge(h, { intent: "success" }, ["Online"]), + badge(h, { intent: "warning" }, ["Away"]), + badge(h, { intent: "danger" }, ["Blocked"]), + badge(h, { intent: "outline" }, ["Bot"]), + ], + ), + menu(h), + ], + ) + +const screen = (h: HtmlBuilder) => + h.div( + [h.Id("gallery"), h.Class("flex h-[640px] w-[1180px] bg-bg font-sans text-fg antialiased")], + [ + sidebar(h), + h.main( + [h.Class("grid flex-1 grid-cols-[1fr_17rem] items-start gap-8 p-8")], + [settings(h), controls(h)], + ), + ], + ) + +for (const theme of ["light", "dark"] as const) { + test(theme, async () => { + if (theme === "dark") document.documentElement.classList.add("dark") + render(screen) + await document.fonts.ready + // One sidebar row under the pointer, so its hover state shows. + await userEvent.hover(page.getByText("engineering")) + const label = import.meta.env.VITE_GALLERY_LABEL ?? "gallery" + await page.screenshot({ path: `${label}-${theme}.png`, element: document.getElementById("gallery")! }) + }) +} diff --git a/apps/web-foldkit/src/entry.ts b/apps/web-foldkit/src/entry.ts index 54ff621aa..73ee4e842 100644 --- a/apps/web-foldkit/src/entry.ts +++ b/apps/web-foldkit/src/entry.ts @@ -1,5 +1,7 @@ import "@fontsource/inter/400.css" import "@fontsource/inter/400-italic.css" +import "@fontsource/inter/500.css" +import "@fontsource/inter/600.css" import "./styles.css" import { Layer } from "effect" diff --git a/apps/web-foldkit/src/shell/channels-sidebar/items.ts b/apps/web-foldkit/src/shell/channels-sidebar/items.ts index b540ccdc7..e55a4eb0a 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/items.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/items.ts @@ -14,7 +14,7 @@ import { dotsMenuTrigger } from "./tree" /** `ChannelItem`, `DmChannelItem` and `DiscoverableChannelItem` (`components/sidebar/*`). */ -export const CHANNEL_ACTIVE = "bg-sidebar-accent font-medium text-sidebar-accent-fg" +export const CHANNEL_ACTIVE = "bg-wash-strong faux-medium text-fg **:data-[slot=icon]:text-fg-3" export interface ItemContext { readonly orgSlug: string @@ -57,7 +57,7 @@ const sidebarBadge = (h: HtmlBuilder, count: number): Html => [ h.Attribute("data-slot", "sidebar-badge"), h.Class( - "absolute inset-y-1/2 right-1.5 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-primary px-1.5 font-semibold text-[11px] text-primary-fg tabular-nums transition-all group-hover/sidebar-item:right-8", + "absolute inset-y-1/2 right-1.5 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-primary px-1.5 font-medium text-[11px] text-primary-fg tabular-nums group-hover/sidebar-item:right-8", ), ], [String(count)], @@ -123,7 +123,8 @@ export const channelItem = ( [h.Class(partners.length > 0 ? "relative shrink-0" : "shrink-0")], [channelIcon(h, entry.channel.icon), ...partnerOrgMarks(h, partners)], ), - label(h, [entry.channel.name]), + // Unread: the name steps up to full ink and medium, the count says how many. + label(h, [entry.channel.name], notificationCount > 0 ? "font-medium text-fg" : undefined), ]), menu, ...badgeIf(h, notificationCount), diff --git a/apps/web-foldkit/src/shell/channels-sidebar/tree.ts b/apps/web-foldkit/src/shell/channels-sidebar/tree.ts index ee5861ae2..2a7fdd663 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/tree.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/tree.ts @@ -66,7 +66,7 @@ export const sectionGroupHeader = ( h.button( [ h.Attribute("type", "button"), - h.Class("flex items-center gap-1 hover:text-fg transition-colors"), + h.Class("flex items-center gap-1 hover:text-fg active:text-fg"), ], [ IconChevronDown(h, { diff --git a/apps/web-foldkit/src/ui/sidebar.ts b/apps/web-foldkit/src/ui/sidebar.ts index 958c6da86..8aae5128c 100644 --- a/apps/web-foldkit/src/ui/sidebar.ts +++ b/apps/web-foldkit/src/ui/sidebar.ts @@ -243,10 +243,10 @@ export const sidebarItem = ( hasHref: false, className: twMerge([ "cursor-default", - "w-full min-w-0 items-center rounded-lg text-left font-medium text-base/6 text-sidebar-fg", + "w-full min-w-0 items-center rounded-lg text-left font-normal text-base/6 text-fg-3", "group/sidebar-item relative col-span-full overflow-hidden focus-visible:outline-hidden", "**:data-[slot=menu-trigger]:absolute **:data-[slot=menu-trigger]:right-0 **:data-[slot=menu-trigger]:flex **:data-[slot=menu-trigger]:h-full **:data-[slot=menu-trigger]:w-auto **:data-[slot=menu-trigger]:items-center **:data-[slot=menu-trigger]:justify-end **:data-[slot=menu-trigger]:px-2.5 **:data-[slot=menu-trigger]:opacity-0 **:data-[slot=menu-trigger]:data-pressed:opacity-100 **:data-[slot=menu-trigger]:has-data-focus:opacity-100 **:data-[slot=menu-trigger]:focus-visible:opacity-100 hover:**:data-[slot=menu-trigger]:opacity-100", - "**:data-[slot=icon]:size-5 **:data-[slot=icon]:shrink-0 **:data-[slot=icon]:text-muted-fg sm:**:data-[slot=icon]:size-4", + "**:data-[slot=icon]:size-5 **:data-[slot=icon]:shrink-0 **:data-[slot=icon]:text-fg-2 sm:**:data-[slot=icon]:size-4", "**:last:data-[slot=icon]:size-5 sm:**:last:data-[slot=icon]:size-4", "has-[[data-slot=avatar]]:has-[[data-slot=sidebar-label]]:gap-2 has-[[data-slot=icon]]:has-[[data-slot=sidebar-label]]:gap-2", "grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] **:last:data-[slot=icon]:ml-auto supports-[grid-template-columns:subgrid]:grid-cols-subgrid sm:text-sm/5", @@ -254,7 +254,7 @@ export const sidebarItem = ( "[--sidebar-current-bg:var(--color-sidebar-primary)] [--sidebar-current-fg:var(--color-sidebar-primary-fg)]", options.isCurrent && "font-medium text-(--sidebar-current-fg) hover:bg-(--sidebar-current-bg) hover:text-(--sidebar-current-fg) **:data-[slot=icon]:text-(--sidebar-current-fg) hover:**:data-[slot=icon]:text-(--sidebar-current-fg) [&_.text-muted-fg]:text-fg/80", - "hover:bg-sidebar-accent hover:text-sidebar-accent-fg hover:**:data-[slot=icon]:text-sidebar-accent-fg", + "hover:bg-wash hover:text-fg hover:**:data-[slot=icon]:text-fg-3 active:bg-wash active:text-fg active:**:data-[slot=icon]:text-fg-3", options.className, ]), }), diff --git a/apps/web-foldkit/tsconfig.json b/apps/web-foldkit/tsconfig.json index 66b1e5dbb..1a3abaeec 100644 --- a/apps/web-foldkit/tsconfig.json +++ b/apps/web-foldkit/tsconfig.json @@ -13,6 +13,9 @@ "jsx": "preserve", "exactOptionalPropertyTypes": false, "types": ["vite/client"], - "paths": { "~/*": ["./src/core/*"] } + "paths": { + "~/*": ["./src/core/*"], + "foldkit-internal/*": ["./node_modules/foldkit/dist/*"] + } } } diff --git a/apps/web-foldkit/vitest.gallery.config.ts b/apps/web-foldkit/vitest.gallery.config.ts new file mode 100644 index 000000000..a77531872 --- /dev/null +++ b/apps/web-foldkit/vitest.gallery.config.ts @@ -0,0 +1,33 @@ +import { realpathSync } from "node:fs" +import { resolve } from "node:path" +import tailwindcss from "@tailwindcss/vite" +import { playwright } from "@vitest/browser-playwright" +import { defineConfig } from "vitest/config" + +/** + * The design gallery: the kit's real views rendered in Chromium with the app's stylesheet, then + * screenshotted in light and dark (`bun run gallery`). + * `foldkit-internal` reaches the renderer the app's runtime uses, which foldkit does not export. + */ +export default defineConfig({ + plugins: [tailwindcss()], + resolve: { + alias: { + "~": resolve(__dirname, "src/core"), + "foldkit-internal": resolve(realpathSync(resolve(__dirname, "node_modules/foldkit")), "dist"), + }, + }, + // One copy of foldkit's modules, so the gallery's runtime frame is the one the views read. + optimizeDeps: { exclude: ["foldkit"] }, + define: { __APP_VERSION__: JSON.stringify("gallery") }, + test: { + include: ["src/**/*.gallery.ts"], + browser: { + enabled: true, + headless: true, + provider: playwright({ contextOptions: { deviceScaleFactor: 2 } }), + viewport: { width: 1180, height: 640 }, + instances: [{ browser: "chromium" }], + }, + }, +})