fix(web): redirect HTTP to HTTPS to fix Safari CORS hang
When users type 'mana.how' (no scheme), Safari and other browsers default to HTTP. Cloudflare/cloudflared serves the page over HTTP without rewriting the scheme. The browser then sends 'Origin: http://mana.how' on every fetch, but mana-auth CORS only allows 'https://mana.how'. Result: every auth request fails, the SSO check throws, AuthGate hangs on the loading spinner forever, and the page never finishes loading. Fix: detect HTTP requests in hooks.server.ts via cf-visitor / x-forwarded-proto / event.url.protocol and 301-redirect to HTTPS before serving any content. Localhost is exempted for dev. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
@ -76,10 +76,30 @@ const APP_SUBDOMAINS = new Set([
|
|||
]);
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Force HTTPS in production. Cloudflare forwards HTTP requests to the
|
||||
// origin without rewriting the scheme, so without this redirect a user
|
||||
// who types `mana.how` (no scheme → HTTP default) loads the page over
|
||||
// HTTP and the browser then sends `Origin: http://mana.how` on every
|
||||
// fetch. mana-auth's CORS only allows `https://mana.how`, so all auth
|
||||
// requests fail and the loader hangs forever.
|
||||
const host = event.request.headers.get('host') || '';
|
||||
const cfVisitor = event.request.headers.get('cf-visitor'); // {"scheme":"http"|"https"}
|
||||
const xfProto = event.request.headers.get('x-forwarded-proto');
|
||||
const isHttp =
|
||||
(cfVisitor && cfVisitor.includes('"scheme":"http"')) ||
|
||||
xfProto === 'http' ||
|
||||
(event.url.protocol === 'http:' && !cfVisitor && !xfProto);
|
||||
const isLocal = host.startsWith('localhost') || host.startsWith('127.');
|
||||
if (isHttp && !isLocal) {
|
||||
return new Response(null, {
|
||||
status: 301,
|
||||
headers: { Location: `https://${host}${event.url.pathname}${event.url.search}` },
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect app subdomains to their path equivalent
|
||||
// e.g. todo.mana.how → mana.how/todo
|
||||
const host = event.request.headers.get('host') || '';
|
||||
const subdomain = host.split('.')[0];
|
||||
const subdomain = host.split('.')[0]; // host already declared above
|
||||
if (APP_SUBDOMAINS.has(subdomain) && event.url.pathname === '/') {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
|
|
|
|||
|
|
@ -250,6 +250,38 @@
|
|||
animation: fadeIn 0.25s ease-out;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
/* Establish a blend-mode stacking context so the grain overlay
|
||||
only blends within this card, not through to the workbench
|
||||
background behind it. */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Per-theme paper-grain overlay. CSS variables come from
|
||||
applyThemeToDocument() in @mana/shared-theme — swap one line in
|
||||
THEME_DEFINITIONS to change the texture for a whole theme. */
|
||||
.page-shell::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background-image: var(--paper-texture, none);
|
||||
background-size: var(--paper-size, 240px 240px);
|
||||
background-repeat: repeat;
|
||||
mix-blend-mode: var(--paper-blend-mode, multiply);
|
||||
opacity: var(--paper-opacity, 0);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
/* Make sure the header, body and resize handle sit above the grain */
|
||||
.page-shell > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
/* A11y: let users who asked for reduced transparency/contrast opt out */
|
||||
@media (prefers-contrast: more) {
|
||||
.page-shell::before {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.page-shell.resizing {
|
||||
box-shadow:
|
||||
|
|
|
|||
20
apps/mana/apps/web/static/textures/paper/LICENSE.txt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
Paper textures used for the per-theme workbench paper-grain overlay.
|
||||
|
||||
Source: ambientCG (https://ambientcg.com)
|
||||
License: CC0 1.0 Universal (Public Domain)
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
No attribution required for commercial or private use. The original 1K
|
||||
PBR material sets were downloaded from ambientCG, only the diffuse /
|
||||
Color map was extracted, then downscaled to 512×512 via `sips` and
|
||||
re-encoded as JPEG (quality 70) to keep web payloads tiny.
|
||||
|
||||
Original asset → local filename:
|
||||
Paper001 → paper-001.jpg
|
||||
Paper002 → paper-002.jpg
|
||||
Paper003 → paper-003.jpg
|
||||
Paper004 → paper-004.jpg
|
||||
Paper005 → paper-005.jpg
|
||||
Paper006 → paper-006.jpg
|
||||
Cardboard001 → cardboard-001.jpg
|
||||
Cardboard002 → cardboard-002.jpg
|
||||
BIN
apps/mana/apps/web/static/textures/paper/cardboard-001.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
apps/mana/apps/web/static/textures/paper/cardboard-002.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
apps/mana/apps/web/static/textures/paper/paper-001.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mana/apps/web/static/textures/paper/paper-002.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/mana/apps/web/static/textures/paper/paper-003.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/mana/apps/web/static/textures/paper/paper-004.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/mana/apps/web/static/textures/paper/paper-005.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/mana/apps/web/static/textures/paper/paper-006.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
|
@ -372,6 +372,12 @@ const lavenderDark: ThemeColors = {
|
|||
|
||||
/**
|
||||
* Complete theme variant definitions
|
||||
*
|
||||
* Each theme can also carry a `paper` descriptor — a tileable texture
|
||||
* the workbench page-shell uses as a grain overlay. Swap the filename
|
||||
* here to change the texture for a whole theme in one place. Assets
|
||||
* live under `apps/<app>/static/textures/paper/` and are CC0-licensed
|
||||
* (see that directory's LICENSE.txt for provenance).
|
||||
*/
|
||||
export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
||||
lume: {
|
||||
|
|
@ -382,6 +388,12 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
|||
hue: 47,
|
||||
light: lumeLight,
|
||||
dark: lumeDark,
|
||||
paper: {
|
||||
url: '/textures/paper/paper-001.jpg',
|
||||
blendMode: 'multiply',
|
||||
opacityLight: 0.4,
|
||||
opacityDark: 0.18,
|
||||
},
|
||||
},
|
||||
nature: {
|
||||
name: 'nature',
|
||||
|
|
@ -391,6 +403,12 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
|||
hue: 122,
|
||||
light: natureLight,
|
||||
dark: natureDark,
|
||||
paper: {
|
||||
url: '/textures/paper/cardboard-001.jpg',
|
||||
blendMode: 'multiply',
|
||||
opacityLight: 0.32,
|
||||
opacityDark: 0.14,
|
||||
},
|
||||
},
|
||||
stone: {
|
||||
name: 'stone',
|
||||
|
|
@ -400,6 +418,12 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
|||
hue: 200,
|
||||
light: stoneLight,
|
||||
dark: stoneDark,
|
||||
paper: {
|
||||
url: '/textures/paper/paper-005.jpg',
|
||||
blendMode: 'multiply',
|
||||
opacityLight: 0.35,
|
||||
opacityDark: 0.15,
|
||||
},
|
||||
},
|
||||
ocean: {
|
||||
name: 'ocean',
|
||||
|
|
@ -409,6 +433,12 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
|||
hue: 199,
|
||||
light: oceanLight,
|
||||
dark: oceanDark,
|
||||
paper: {
|
||||
url: '/textures/paper/paper-003.jpg',
|
||||
blendMode: 'multiply',
|
||||
opacityLight: 0.3,
|
||||
opacityDark: 0.12,
|
||||
},
|
||||
},
|
||||
// Extended themes (not in PillNav by default, can be pinned)
|
||||
sunset: {
|
||||
|
|
@ -419,6 +449,12 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
|||
hue: 15,
|
||||
light: sunsetLight,
|
||||
dark: sunsetDark,
|
||||
paper: {
|
||||
url: '/textures/paper/paper-006.jpg',
|
||||
blendMode: 'multiply',
|
||||
opacityLight: 0.38,
|
||||
opacityDark: 0.16,
|
||||
},
|
||||
},
|
||||
midnight: {
|
||||
name: 'midnight',
|
||||
|
|
@ -428,6 +464,12 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
|||
hue: 260,
|
||||
light: midnightLight,
|
||||
dark: midnightDark,
|
||||
paper: {
|
||||
url: '/textures/paper/paper-004.jpg',
|
||||
blendMode: 'overlay',
|
||||
opacityLight: 0.35,
|
||||
opacityDark: 0.22,
|
||||
},
|
||||
},
|
||||
rose: {
|
||||
name: 'rose',
|
||||
|
|
@ -437,6 +479,12 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
|||
hue: 340,
|
||||
light: roseLight,
|
||||
dark: roseDark,
|
||||
paper: {
|
||||
url: '/textures/paper/paper-002.jpg',
|
||||
blendMode: 'multiply',
|
||||
opacityLight: 0.32,
|
||||
opacityDark: 0.14,
|
||||
},
|
||||
},
|
||||
lavender: {
|
||||
name: 'lavender',
|
||||
|
|
@ -446,6 +494,12 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
|||
hue: 270,
|
||||
light: lavenderLight,
|
||||
dark: lavenderDark,
|
||||
paper: {
|
||||
url: '/textures/paper/cardboard-002.jpg',
|
||||
blendMode: 'soft-light',
|
||||
opacityLight: 0.4,
|
||||
opacityDark: 0.18,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,33 @@ export interface ThemeVariantDefinition {
|
|||
hue: number;
|
||||
light: ThemeColors;
|
||||
dark: ThemeColors;
|
||||
/**
|
||||
* Optional "paper grain" overlay for workbench page surfaces.
|
||||
* Each theme can ship its own tileable texture to give pages a
|
||||
* distinct tactile character. The consuming app is responsible for
|
||||
* serving the asset at the given URL (typically under `/textures/`).
|
||||
* When undefined, no paper overlay is applied for this theme.
|
||||
*/
|
||||
paper?: {
|
||||
/** URL / absolute path to a seamless tileable texture */
|
||||
url: string;
|
||||
/** CSS blend-mode for the overlay vs. the underlying card bg */
|
||||
blendMode?:
|
||||
| 'multiply'
|
||||
| 'overlay'
|
||||
| 'soft-light'
|
||||
| 'hard-light'
|
||||
| 'screen'
|
||||
| 'darken'
|
||||
| 'lighten'
|
||||
| 'color-burn';
|
||||
/** Opacity of the paper layer in light mode (0..1, default 0.35) */
|
||||
opacityLight?: number;
|
||||
/** Opacity of the paper layer in dark mode (0..1, default 0.15) */
|
||||
opacityDark?: number;
|
||||
/** CSS background-size (default "240px 240px") */
|
||||
size?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -112,6 +112,25 @@ export function applyThemeToDocument(
|
|||
root.style.setProperty(key, value);
|
||||
});
|
||||
|
||||
// Set per-theme paper-grain CSS variables (consumed by PageShell).
|
||||
// Unset the vars for themes without a paper config so they don't
|
||||
// leak across theme switches.
|
||||
const paper = THEME_DEFINITIONS[variant]?.paper;
|
||||
if (paper) {
|
||||
root.style.setProperty('--paper-texture', `url("${paper.url}")`);
|
||||
root.style.setProperty('--paper-blend-mode', paper.blendMode ?? 'multiply');
|
||||
root.style.setProperty(
|
||||
'--paper-opacity',
|
||||
String(effectiveMode === 'dark' ? (paper.opacityDark ?? 0.15) : (paper.opacityLight ?? 0.35))
|
||||
);
|
||||
root.style.setProperty('--paper-size', paper.size ?? '240px 240px');
|
||||
} else {
|
||||
root.style.removeProperty('--paper-texture');
|
||||
root.style.removeProperty('--paper-blend-mode');
|
||||
root.style.removeProperty('--paper-opacity');
|
||||
root.style.removeProperty('--paper-size');
|
||||
}
|
||||
|
||||
// Set data-theme attribute
|
||||
root.setAttribute('data-theme', variant);
|
||||
|
||||
|
|
|
|||