diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 129158d5..24202b62 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,9 @@ # Project: `froq` → https://froq.pages.dev # Custom domain: Dashboard → Pages → froq → Custom domains → froq.me # +# `main` deploys the production branch. Pull requests deploy a preview +# branch (not production) so the PR can be clicked before merge. +# # Create token: https://dash.cloudflare.com/profile/api-tokens # (Workers template + Cloudflare Pages:Edit is enough) @@ -16,15 +19,17 @@ name: Deploy Nuxt to Cloudflare Pages on: push: branches: [main] + pull_request: workflow_dispatch: permissions: contents: read deployments: write + pull-requests: write concurrency: - group: pages - cancel-in-progress: false + group: pages-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: deploy: @@ -52,8 +57,18 @@ jobs: NUXT_PUBLIC_FROQ_API: ${{ secrets.NUXT_PUBLIC_FROQ_API }} run: pnpm generate + - name: Resolve Pages branch + id: pages + run: | + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + echo "branch=${GITHUB_HEAD_REF}" >> "$GITHUB_OUTPUT" + else + echo "branch=main" >> "$GITHUB_OUTPUT" + fi + # Use workspace wrangler (workers/froq-api) so the action does not # `pnpm add wrangler` under pnpm 11 (ERR_PNPM_IGNORED_BUILDS). + # Preview PRs must not pass --branch=main (that is production). - name: Deploy to Cloudflare Pages id: deploy uses: cloudflare/wrangler-action@v4 @@ -63,9 +78,14 @@ jobs: gitHubToken: ${{ secrets.GITHUB_TOKEN }} workingDirectory: workers/froq-api packageManager: pnpm - command: pages deploy ../../.output/public --project-name=froq --branch=main --commit-dirty=true + command: pages deploy ../../.output/public --project-name=froq --branch=${{ steps.pages.outputs.branch }} --commit-dirty=true - name: Deployment URL run: | echo "url: ${{ steps.deploy.outputs.deployment-url }}" echo "alias: ${{ steps.deploy.outputs.pages-deployment-alias-url }}" + { + echo "### Cloudflare Pages" + echo "- url: ${{ steps.deploy.outputs.deployment-url }}" + echo "- alias: ${{ steps.deploy.outputs.pages-deployment-alias-url }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/app/assets/css/main.css b/app/assets/css/main.css index eef7cd7a..17377ea5 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -111,3 +111,25 @@ rt { [un-prose] .math-inline { --uno: 'text-[1.05em]'; } + +html[data-sticky-cursor], +html[data-sticky-cursor] * { + cursor: none !important; +} + +html[data-sticky-cursor] :is( + input, + textarea, + select, + [contenteditable]:not([contenteditable='false']) +) { + cursor: text !important; +} + +html[data-sticky-cursor] [un-cursor*='grab'] { + cursor: grab !important; +} + +html[data-sticky-cursor] [un-cursor*='grab']:active { + cursor: grabbing !important; +} diff --git a/app/components/SiteStickyCursor.vue b/app/components/SiteStickyCursor.vue new file mode 100644 index 00000000..f0413b0a --- /dev/null +++ b/app/components/SiteStickyCursor.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 412e9dbf..344382d6 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -24,5 +24,6 @@ const hideChromeMast = computed(() => isHome.value || isHub.value) + diff --git a/app/utils/stickyCursor.ts b/app/utils/stickyCursor.ts new file mode 100644 index 00000000..0bae1a76 --- /dev/null +++ b/app/utils/stickyCursor.ts @@ -0,0 +1,82 @@ +export const STICKY_CURSOR_FIELD_SEL + = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])' + +export const STICKY_CURSOR_HOT_SEL = [ + 'a[href]', + 'button:not([disabled])', + '[role="button"]', + 'summary', + 'label[for]', + '[un-cursor-pointer]', +].join(', ') + +export const STICKY_CURSOR_NATIVE_SEL + = `${STICKY_CURSOR_FIELD_SEL}, [un-cursor*="grab"], iframe` + +export const STICKY_CURSOR_MQ + = '(pointer: fine) and (hover: hover)' + +export const STICKY_CURSOR_STYLE_ID = 'sticky-cursor-css' + +const STICKY_CURSOR_CSS = ` +html[data-sticky-cursor], +html[data-sticky-cursor] * { + cursor: none !important; +} +html[data-sticky-cursor] :is( + input, + textarea, + select, + [contenteditable]:not([contenteditable='false']) +) { + cursor: text !important; +} +html[data-sticky-cursor] [un-cursor*='grab'] { + cursor: grab !important; +} +html[data-sticky-cursor] [un-cursor*='grab']:active { + cursor: grabbing !important; +} +` + +export function paintStickyCursorCss(on: boolean) { + if (typeof document === 'undefined') + return + const existing = document.getElementById(STICKY_CURSOR_STYLE_ID) + if (!on) { + existing?.remove() + return + } + if (existing) + return + const style = document.createElement('style') + style.id = STICKY_CURSOR_STYLE_ID + style.textContent = STICKY_CURSOR_CSS + document.head.appendChild(style) +} + +export type StickyCursorAim = 'idle' | 'hot' | 'native' + +export function stickyCursorAim(target: EventTarget | null): StickyCursorAim { + if (!(target instanceof Element)) + return 'idle' + if (target.closest(STICKY_CURSOR_NATIVE_SEL)) + return 'native' + if (target.closest(STICKY_CURSOR_HOT_SEL)) + return 'hot' + return 'idle' +} + +export function stickyCursorLag(): boolean { + if (typeof window === 'undefined') + return false + return !window.matchMedia('(prefers-reduced-motion: reduce)').matches +} + +export function stickyCursorCapable(pointerType?: string): boolean { + if (typeof window === 'undefined') + return false + if (pointerType === 'mouse' || pointerType === 'pen') + return true + return window.matchMedia(STICKY_CURSOR_MQ).matches +} diff --git a/tests/stickyCursor.test.ts b/tests/stickyCursor.test.ts new file mode 100644 index 00000000..7ad499ab --- /dev/null +++ b/tests/stickyCursor.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { stickyCursorAim } from '../app/utils/stickyCursor' + +describe('stickyCursorAim', () => { + it('returns idle for empty targets', () => { + expect(stickyCursorAim(null)).toBe('idle') + }) + + it('marks links and buttons hot', () => { + const a = document.createElement('a') + a.href = '/posts' + document.body.append(a) + expect(stickyCursorAim(a)).toBe('hot') + a.remove() + + const btn = document.createElement('button') + document.body.append(btn) + expect(stickyCursorAim(btn)).toBe('hot') + btn.remove() + }) + + it('keeps native cursor on fields and grab handles', () => { + const input = document.createElement('input') + document.body.append(input) + expect(stickyCursorAim(input)).toBe('native') + input.remove() + + const pin = document.createElement('div') + pin.setAttribute('un-cursor', 'grab active:grabbing') + document.body.append(pin) + expect(stickyCursorAim(pin)).toBe('native') + pin.remove() + }) + + it('lets native win over a wrapping link', () => { + const a = document.createElement('a') + a.href = '/' + const field = document.createElement('textarea') + a.append(field) + document.body.append(a) + expect(stickyCursorAim(field)).toBe('native') + a.remove() + }) +})