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()
+ })
+})