diff --git a/.github/workflows/linear-releases.yml b/.github/workflows/linear-releases.yml new file mode 100644 index 00000000..7bd6cf0b --- /dev/null +++ b/.github/workflows/linear-releases.yml @@ -0,0 +1,55 @@ +name: Linear releases + +on: + deployment_status: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.deployment.environment }}-${{ github.event.deployment.sha }} + cancel-in-progress: false + +jobs: + staging: + name: staging + if: >- + github.event.deployment_status.state == 'success' && + github.event.deployment.creator.login == 'railway-app[bot]' && + github.event.deployment.environment == 'ePDS / pr-base' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.deployment.sha }} + fetch-depth: 0 + + # This reports what reached staging to Linear. Release-note generation + # stays in the existing Changesets/GitHub Release flow. + - uses: linear/linear-release-action@0917777589db006387af296dde440385f88d6ac4 # v0.10.0 + with: + access_key: ${{ secrets.LINEAR_STAGING_ACCESS_KEY }} + name: staging-${{ github.event.deployment.sha }} + version: staging-${{ github.event.deployment.sha }} + + production: + name: production + if: >- + github.event.deployment_status.state == 'success' && + github.event.deployment.creator.login == 'railway-app[bot]' && + github.event.deployment.environment == 'ePDS / production' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.deployment.sha }} + fetch-depth: 0 + + # This reports what reached production to Linear. Release-note generation + # stays in the existing Changesets/GitHub Release flow. + - uses: linear/linear-release-action@0917777589db006387af296dde440385f88d6ac4 # v0.10.0 + with: + access_key: ${{ secrets.LINEAR_PRODUCTION_ACCESS_KEY }} + name: production-${{ github.event.deployment.sha }} + version: production-${{ github.event.deployment.sha }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b0bcff..beb73d0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # ePDS +## 0.6.3 + +### Who should read this release + +- **End users:** + - [The final sign-in screen now lines up its message card with the page title.](#v0.6.3-the-final-sign-in-screen-now-lines-up-its-message-card-with) + +### Patch Changes + +- [#169](https://github.com/hypercerts-org/ePDS/pull/169) [`cc65707`](https://github.com/hypercerts-org/ePDS/commit/cc65707ef4e0cfc7d4442bbb57ccd2c8639ec589) Thanks [@Kzoeps](https://github.com/Kzoeps)! - The final sign-in screen now lines up its message card with the page title. + + **Affects:** End users + + **End users:** After approving an app sign-in, the "Login complete" screen now keeps the "You are being redirected..." card visually aligned with the title instead of sitting slightly lower on the page. + ## 0.6.2 ### Who should read this release diff --git a/package.json b/package.json index 65da3eb7..41a8374a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ePDS", - "version": "0.6.2", + "version": "0.6.3", "private": true, "description": "ePDS — extended Personal Data Server for AT Protocol with passwordless OTP authentication", "license": "MIT", diff --git a/packages/demo/src/__tests__/theme.test.ts b/packages/demo/src/__tests__/theme.test.ts new file mode 100644 index 00000000..f081566f --- /dev/null +++ b/packages/demo/src/__tests__/theme.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { getPageTheme, getTheme } from '../lib/theme' + +const originalTheme = process.env.EPDS_CLIENT_THEME + +afterEach(() => { + if (originalTheme === undefined) { + delete process.env.EPDS_CLIENT_THEME + return + } + + process.env.EPDS_CLIENT_THEME = originalTheme +}) + +describe('getTheme', () => { + it('returns null when no theme is configured', () => { + delete process.env.EPDS_CLIENT_THEME + + expect(getTheme()).toBeNull() + }) + + it('returns null for an unknown theme', () => { + process.env.EPDS_CLIENT_THEME = 'forest' + + expect(getTheme()).toBeNull() + }) + + it('returns the ocean theme with page values and injected CSS', () => { + process.env.EPDS_CLIENT_THEME = 'ocean' + + const theme = getTheme() + + expect(theme?.page.primary).toBe('#8b5cf6') + expect(theme?.injectedCss).toContain( + ':root { --branding-color-primary: 139 92 246; --branding-color-primary-contrast: 26 16 51; }', + ) + expect(theme?.injectedCss).toContain(String.raw`.md\:bg-slate-100`) + expect(theme?.injectedCss).toContain( + '.account-info { background: #2d1a4f; color: #c4b5fd; }', + ) + }) + + it('returns the amber page theme', () => { + process.env.EPDS_CLIENT_THEME = 'amber' + + expect(getPageTheme()).toMatchObject({ + bg: '#1a1208', + primary: '#f59e0b', + primaryText: '#1a1208', + }) + }) +}) diff --git a/packages/demo/src/app/components/LoginForm.tsx b/packages/demo/src/app/components/LoginForm.tsx index 06989549..46e882c9 100644 --- a/packages/demo/src/app/components/LoginForm.tsx +++ b/packages/demo/src/app/components/LoginForm.tsx @@ -132,7 +132,7 @@ export function LoginForm() { padding: '14px 28px', fontSize: '16px', fontWeight: 500, - color: '#ffffff', + color: 'var(--theme-primary-text, #ffffff)', background: submitting ? '#4a4a4a' : 'var(--theme-primary, #2563eb)', diff --git a/packages/demo/src/app/components/PageShell.tsx b/packages/demo/src/app/components/PageShell.tsx index 99ea2a60..bb3842d0 100644 --- a/packages/demo/src/app/components/PageShell.tsx +++ b/packages/demo/src/app/components/PageShell.tsx @@ -28,6 +28,7 @@ export function PageShell({ children }: PageShellProps) { '--theme-text-muted': t.textMuted, '--theme-text-hint': t.textHint, '--theme-primary': t.primary, + '--theme-primary-text': t.primaryText, '--theme-primary-hover': t.primaryHover, '--theme-input-bg': t.inputBg, '--theme-input-border': t.inputBorder, diff --git a/packages/demo/src/app/components/SignInButton.tsx b/packages/demo/src/app/components/SignInButton.tsx index 0e2ec55f..13384944 100644 --- a/packages/demo/src/app/components/SignInButton.tsx +++ b/packages/demo/src/app/components/SignInButton.tsx @@ -26,7 +26,7 @@ export function SignInButton({ padding: '14px 28px', fontSize: '16px', fontWeight: 500, - color: '#ffffff', + color: 'var(--theme-primary-text, #ffffff)', background: submitting ? '#4a4a4a' : 'var(--theme-primary, #2563eb)', border: 'none', borderRadius: '8px', diff --git a/packages/demo/src/app/welcome/page.tsx b/packages/demo/src/app/welcome/page.tsx index 20956a3c..9aac3205 100644 --- a/packages/demo/src/app/welcome/page.tsx +++ b/packages/demo/src/app/welcome/page.tsx @@ -135,7 +135,7 @@ export default async function Welcome() { padding: '14px 28px', fontSize: '16px', fontWeight: 500, - color: '#ffffff', + color: t?.primaryText ?? '#ffffff', background: t?.primary ?? '#2563eb', border: 'none', borderRadius: '8px', diff --git a/packages/demo/src/lib/theme.ts b/packages/demo/src/lib/theme.ts index c2549558..9f12846b 100644 --- a/packages/demo/src/lib/theme.ts +++ b/packages/demo/src/lib/theme.ts @@ -41,6 +41,8 @@ export interface PageTheme { textHint: string /** Primary button background */ primary: string + /** Primary button text */ + primaryText: string /** Primary button hover background */ primaryHover: string /** Input background */ @@ -62,158 +64,153 @@ export interface Theme { injectedCss: string } -// --------------------------------------------------------------------------- -// Presets -// --------------------------------------------------------------------------- +interface InjectedCssOptions { + primaryChannels: string + primaryContrastChannels: string + fieldLabel: string + secondarySurfaceHover: string + accountInfoBg: string + accountInfoText: string +} -const ocean: Theme = { - page: { - bg: '#1a1033', - surface: '#251845', - surfaceShadow: '0 2px 12px rgba(0,0,0,0.4)', - text: '#e8e0f0', - textMuted: '#a78bbd', - textHint: '#7c6894', - primary: '#8b5cf6', - primaryHover: '#7c3aed', - inputBg: '#1a1033', - inputBorder: '#3d2a5c', - focusBorder: '#8b5cf6', - errorText: '#fca5a5', - errorBg: '#450a0a', - logoBg: '#8b5cf6', - }, - injectedCss: [ +function buildInjectedCss( + page: PageTheme, + options: InjectedCssOptions, +): string { + const { + primaryChannels, + primaryContrastChannels, + fieldLabel, + secondarySurfaceHover, + accountInfoBg, + accountInfoText, + } = options + + return [ // Provider-UI consent page: recolour Tailwind utilities via the // --branding-color-* custom props the UI reads through // `rgb(var(--branding-color-primary))`. Channels are space-separated. - ':root { --branding-color-primary: 139 92 246; --branding-color-primary-contrast: 26 16 51; }', + `:root { --branding-color-primary: ${primaryChannels}; --branding-color-primary-contrast: ${primaryContrastChannels}; }`, // Body background & primary text. Provider-UI sets these on
// via `bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100`, // so class-specific selectors (plus !important for dark-mode) are // needed to override Tailwind's equal-specificity rules. - 'body { background: #1a1033 !important; color: #e8e0f0 !important; }', - 'html { background: #1a1033; }', + `body { background: ${page.bg} !important; color: ${page.text} !important; }`, + `html { background: ${page.bg}; }`, // Consent page's "left strip" header column (md:bg-slate-100 // md:dark:bg-slate-800) — paint it a shade lighter than body so // it reads as a distinct surface, matching the demo's own cards. - '.md\\:bg-slate-100, .md\\:dark\\:bg-slate-800 { background-color: #251845 !important; }', - '.md\\:dark\\:border-slate-700 { border-color: #3d2a5c !important; }', + String.raw`.md\:bg-slate-100, .md\:dark\:bg-slate-800 { background-color: ${page.surface} !important; }`, + String.raw`.md\:dark\:border-slate-700 { border-color: ${page.inputBorder} !important; }`, + `main { background: ${page.surface} !important; border-color: ${page.inputBorder} !important; box-shadow: ${page.surfaceShadow} !important; }`, // Three-tone text hierarchy on the consent page. Provider-UI uses // `text-slate-{100,200,300,400}` + `text-neutral-{400,500}` for // primary / muted / hint text; remap to the theme's three shades. - '.text-slate-900, .dark\\:text-slate-100, .text-slate-800, .dark\\:text-slate-200, .text-gray-800, .dark\\:text-gray-200 { color: #e8e0f0 !important; }', - '.text-slate-700, .text-slate-600, .dark\\:text-slate-300, .dark\\:text-slate-400 { color: #a78bbd !important; }', - '.text-slate-500, .text-gray-500, .text-neutral-500, .dark\\:text-neutral-400, .dark\\:text-gray-300, .dark\\:text-gray-400 { color: #7c6894 !important; }', + String.raw`.text-slate-900, .dark\:text-slate-100, .text-slate-800, .dark\:text-slate-200, .text-gray-800, .dark\:text-gray-200 { color: ${page.text} !important; }`, + String.raw`.text-slate-700, .text-slate-600, .dark\:text-slate-300, .dark\:text-slate-400 { color: ${page.textMuted} !important; }`, + String.raw`.text-slate-500, .text-gray-500, .text-neutral-500, .dark\:text-neutral-400, .dark\:text-gray-300, .dark\:text-gray-400 { color: ${page.textHint} !important; }`, // Consent page secondary buttons (Deny access etc.) default to // .bg-gray-300 / .dark:bg-slate-600, which reads as a jarring // slate-grey against the themed card. Tint them to a muted surface // that harmonises with the palette. - '.bg-gray-300, .dark\\:bg-slate-600, .bg-gray-200, .dark\\:bg-gray-800, .dark\\:bg-gray-700 { background-color: #3d2a5c !important; color: #e8e0f0 !important; }', + String.raw`.bg-gray-100, .bg-gray-300, .dark\:bg-slate-600, .bg-gray-200, .dark\:bg-gray-800, .dark\:bg-gray-700 { background-color: ${page.inputBorder} !important; color: ${page.text} !important; border-color: ${page.inputBorder} !important; }`, + String.raw`.hover\:bg-gray-200:hover, .dark\:hover\:bg-gray-700:hover { background-color: ${secondarySurfaceHover} !important; }`, // auth-service hand-rolled markup - '.container { background: #251845; box-shadow: 0 2px 12px rgba(0,0,0,0.4); }', - 'h1 { color: #e8e0f0; }', - '.subtitle { color: #a78bbd; }', - '.field label { color: #d4c4e8; }', - '.field input { background: #1a1033; border-color: #3d2a5c; color: #e8e0f0; }', - '.field input:focus { border-color: #8b5cf6; }', - '.field input::placeholder { color: #7c6894; }', - '.otp-input { color: #e8e0f0; }', - '.otp-input:focus { border-color: #8b5cf6 !important; }', - '.btn-primary { background: #8b5cf6; }', - '.btn-primary:hover { background: #7c3aed; }', - '.btn-secondary { color: #a78bbd; }', - '.btn-social { background: #1a1033; border-color: #3d2a5c; color: #e8e0f0; }', - '.btn-social:hover { background: #3d2a5c; }', - '.divider { color: #7c6894; }', - '.divider::before, .divider::after { background: #3d2a5c; }', - '.error { background: #450a0a; color: #fca5a5; }', - '.recovery-link { color: #7c6894; }', - '.recovery-link:hover { color: #a78bbd; }', - '.handle-row { border-color: #3d2a5c; }', - '.handle-suffix { color: #7c6894; background: #1a1033; border-color: #3d2a5c; }', + `:root { --page-bg: ${page.bg}; --card-bg: ${page.surface}; --card-border: ${page.inputBorder}; --input-bg: ${page.inputBg}; --input-border: ${page.inputBorder}; --muted-foreground: ${page.textMuted}; --focus-border: ${page.focusBorder}; --btn-secondary-border: ${page.inputBorder}; }`, + `.container { background: ${page.surface}; box-shadow: ${page.surfaceShadow}; }`, + `h1 { color: ${page.text}; }`, + `.subtitle { color: ${page.textMuted}; }`, + `.field label { color: ${fieldLabel}; }`, + `.field input { background: ${page.inputBg}; border-color: ${page.inputBorder}; color: ${page.text}; }`, + `.field input:focus { border-color: ${page.focusBorder}; }`, + `.field input::placeholder { color: ${page.textHint}; }`, + `.otp-box { color: ${page.text}; }`, + `.otp-box:focus { border-color: ${page.focusBorder} !important; }`, + `.btn-primary { background: ${page.primary}; color: ${page.primaryText}; }`, + `.btn-primary:hover { background: ${page.primaryHover}; }`, + `.btn-secondary { color: ${page.textMuted}; }`, + `.btn-social { background: ${page.inputBg}; border-color: ${page.inputBorder}; color: ${page.text}; }`, + `.btn-atproto { background: ${page.inputBg} !important; border-color: ${page.inputBorder} !important; color: ${page.text} !important; }`, + `.btn-social:hover { background: ${page.inputBorder}; }`, + `.divider { color: ${page.textHint}; }`, + `.divider::before, .divider::after { background: ${page.inputBorder}; }`, + `.error { background: ${page.errorBg}; color: ${page.errorText}; }`, + `.flash-msg.error { background: ${page.errorBg}; color: ${page.errorText}; }`, + `.recovery-link { color: ${page.textHint}; }`, + `.recovery-link:hover { color: ${page.textMuted}; }`, + `.handle-row { border-color: ${page.inputBorder}; }`, + `.handle-suffix { color: ${page.textHint}; background: ${page.inputBg}; border-color: ${page.inputBorder}; }`, '.status.available { color: #4ade80; }', - '.status.taken { color: #fca5a5; }', - '.status.checking { color: #7c6894; }', - '.permissions { background: #1a1033; }', + `.status.taken { color: ${page.errorText}; }`, + `.status.checking { color: ${page.textHint}; }`, + `.permissions { background: ${page.inputBg}; }`, '.permissions li::before { color: #4ade80; }', - '.account-info { background: #2d1a4f; color: #c4b5fd; }', - ].join(' '), + `.account-info { background: ${accountInfoBg}; color: ${accountInfoText}; }`, + ].join(' ') +} + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +const oceanPage: PageTheme = { + bg: '#1a1033', + surface: '#251845', + surfaceShadow: '0 2px 12px rgba(0,0,0,0.4)', + text: '#e8e0f0', + textMuted: '#a78bbd', + textHint: '#7c6894', + primary: '#8b5cf6', + primaryText: '#ffffff', + primaryHover: '#7c3aed', + inputBg: '#1a1033', + inputBorder: '#3d2a5c', + focusBorder: '#8b5cf6', + errorText: '#fca5a5', + errorBg: '#450a0a', + logoBg: '#8b5cf6', +} + +const ocean: Theme = { + page: oceanPage, + injectedCss: buildInjectedCss(oceanPage, { + primaryChannels: '139 92 246', + primaryContrastChannels: '26 16 51', + fieldLabel: '#d4c4e8', + secondarySurfaceHover: '#4c3570', + accountInfoBg: '#2d1a4f', + accountInfoText: '#c4b5fd', + }), +} + +const amberPage: PageTheme = { + bg: '#1a1208', + surface: '#2d2010', + surfaceShadow: '0 2px 12px rgba(0,0,0,0.4)', + text: '#fef3c7', + textMuted: '#d4a574', + textHint: '#b98b55', + primary: '#f59e0b', + primaryText: '#1a1208', + primaryHover: '#d97706', + inputBg: '#1a1208', + inputBorder: '#4a3520', + focusBorder: '#f59e0b', + errorText: '#fca5a5', + errorBg: '#450a0a', + logoBg: '#f59e0b', } const amber: Theme = { - page: { - bg: '#1a1208', - surface: '#2d2010', - surfaceShadow: '0 2px 12px rgba(0,0,0,0.4)', - text: '#fef3c7', - textMuted: '#d4a574', - textHint: '#a07848', - primary: '#f59e0b', - primaryHover: '#d97706', - inputBg: '#1a1208', - inputBorder: '#4a3520', - focusBorder: '#f59e0b', - errorText: '#fca5a5', - errorBg: '#450a0a', - logoBg: '#f59e0b', - }, - injectedCss: [ - // Provider-UI consent page: recolour Tailwind utilities via the - // --branding-color-* custom props the UI reads through - // `rgb(var(--branding-color-primary))`. Channels are space-separated. - ':root { --branding-color-primary: 245 158 11; --branding-color-primary-contrast: 26 18 8; }', - // Body background & primary text. Provider-UI sets these on - // via `bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100`, - // so class-specific selectors (plus !important for dark-mode) are - // needed to override Tailwind's equal-specificity rules. - 'body { background: #1a1208 !important; color: #fef3c7 !important; }', - 'html { background: #1a1208; }', - // Consent page's "left strip" header column (md:bg-slate-100 - // md:dark:bg-slate-800) — paint it a shade lighter than body so - // it reads as a distinct surface, matching the demo's own cards. - '.md\\:bg-slate-100, .md\\:dark\\:bg-slate-800 { background-color: #2d2010 !important; }', - '.md\\:dark\\:border-slate-700 { border-color: #4a3520 !important; }', - // Three-tone text hierarchy on the consent page. Provider-UI uses - // `text-slate-{100,200,300,400}` + `text-neutral-{400,500}` for - // primary / muted / hint text; remap to the theme's three shades. - '.text-slate-900, .dark\\:text-slate-100, .text-slate-800, .dark\\:text-slate-200, .text-gray-800, .dark\\:text-gray-200 { color: #fef3c7 !important; }', - '.text-slate-700, .text-slate-600, .dark\\:text-slate-300, .dark\\:text-slate-400 { color: #d4a574 !important; }', - '.text-slate-500, .text-gray-500, .text-neutral-500, .dark\\:text-neutral-400, .dark\\:text-gray-300, .dark\\:text-gray-400 { color: #a07848 !important; }', - // Consent page secondary buttons (Deny access etc.) default to - // .bg-gray-300 / .dark:bg-slate-600, which reads as a jarring - // slate-grey against the themed card. Tint them to a muted surface - // that harmonises with the palette. - '.bg-gray-300, .dark\\:bg-slate-600, .bg-gray-200, .dark\\:bg-gray-800, .dark\\:bg-gray-700 { background-color: #4a3520 !important; color: #fef3c7 !important; }', - // auth-service hand-rolled markup - '.container { background: #2d2010; box-shadow: 0 2px 12px rgba(0,0,0,0.4); }', - 'h1 { color: #fef3c7; }', - '.subtitle { color: #d4a574; }', - '.field label { color: #e8d5b0; }', - '.field input { background: #1a1208; border-color: #4a3520; color: #fef3c7; }', - '.field input:focus { border-color: #f59e0b; }', - '.field input::placeholder { color: #a07848; }', - '.otp-input { color: #fef3c7; }', - '.otp-input:focus { border-color: #f59e0b !important; }', - '.btn-primary { background: #f59e0b; color: #1a1208; }', - '.btn-primary:hover { background: #d97706; }', - '.btn-secondary { color: #d4a574; }', - '.btn-social { background: #1a1208; border-color: #4a3520; color: #fef3c7; }', - '.btn-social:hover { background: #4a3520; }', - '.divider { color: #a07848; }', - '.divider::before, .divider::after { background: #4a3520; }', - '.error { background: #450a0a; color: #fca5a5; }', - '.recovery-link { color: #a07848; }', - '.recovery-link:hover { color: #d4a574; }', - '.handle-row { border-color: #4a3520; }', - '.handle-suffix { color: #a07848; background: #1a1208; border-color: #4a3520; }', - '.status.available { color: #4ade80; }', - '.status.taken { color: #fca5a5; }', - '.status.checking { color: #a07848; }', - '.permissions { background: #1a1208; }', - '.permissions li::before { color: #4ade80; }', - '.account-info { background: #3d2a10; color: #fbbf24; }', - ].join(' '), + page: amberPage, + injectedCss: buildInjectedCss(amberPage, { + primaryChannels: '245 158 11', + primaryContrastChannels: '26 18 8', + fieldLabel: '#e8d5b0', + secondarySurfaceHover: '#5a4228', + accountInfoBg: '#3d2a10', + accountInfoText: '#fbbf24', + }), } const presets: Record