Polish mobile and web experience
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
const DARK_ACTION_FOREGROUND = "#18181B";
|
||||
const LIGHT_ACTION_FOREGROUND = "#FFFFFF";
|
||||
const MIN_TEXT_CONTRAST = 4.5;
|
||||
|
||||
function parseHexColor(color: string): [number, number, number] | null {
|
||||
const value = color.trim().replace(/^#/, "");
|
||||
const expanded =
|
||||
value.length === 3
|
||||
? value
|
||||
.split("")
|
||||
.map((character) => character.repeat(2))
|
||||
.join("")
|
||||
: value.slice(0, 6);
|
||||
|
||||
if (expanded.length !== 6 || !/^[0-9a-f]+$/i.test(expanded)) return null;
|
||||
|
||||
return [
|
||||
Number.parseInt(expanded.slice(0, 2), 16),
|
||||
Number.parseInt(expanded.slice(2, 4), 16),
|
||||
Number.parseInt(expanded.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function luminance(color: string): number | null {
|
||||
const rgb = parseHexColor(color);
|
||||
if (!rgb) return null;
|
||||
|
||||
const channels = rgb.map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
|
||||
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
|
||||
}
|
||||
|
||||
function contrastRatio(foreground: string, background: string): number | null {
|
||||
const foregroundLuminance = luminance(foreground);
|
||||
const backgroundLuminance = luminance(background);
|
||||
if (foregroundLuminance == null || backgroundLuminance == null) return null;
|
||||
|
||||
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
||||
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/** Chooses a readable action label/icon color while preserving a valid requested color. */
|
||||
export function resolveActionForeground(background: string, requested: string): string {
|
||||
const requestedContrast = contrastRatio(requested, background);
|
||||
if (requestedContrast == null || requestedContrast >= MIN_TEXT_CONTRAST) {
|
||||
return requested;
|
||||
}
|
||||
|
||||
const darkContrast = contrastRatio(DARK_ACTION_FOREGROUND, background) ?? 0;
|
||||
const lightContrast = contrastRatio(LIGHT_ACTION_FOREGROUND, background) ?? 0;
|
||||
return darkContrast >= lightContrast ? DARK_ACTION_FOREGROUND : LIGHT_ACTION_FOREGROUND;
|
||||
}
|
||||
@@ -11,6 +11,18 @@ const CHUNK_MARKER = "\u0001ba-chunks:";
|
||||
const SESSION_TOKEN_COOKIE_PART =
|
||||
/(?:^|;\s*)(?:__Secure-)?[^=]*session_token=([^;]+)/;
|
||||
const AUTH_COOKIE_DEBUG = process.env.EXPO_PUBLIC_AUTH_COOKIE_DEBUG === "1";
|
||||
const lastAuthCookieDebugState = new Map<string, string>();
|
||||
|
||||
function debugAuthCookie(event: string, storagePrefix: string, details: Record<string, unknown>) {
|
||||
if (!AUTH_COOKIE_DEBUG) return;
|
||||
|
||||
const key = `${event}:${storagePrefix}`;
|
||||
const state = JSON.stringify(details);
|
||||
if (lastAuthCookieDebugState.get(key) === state) return;
|
||||
|
||||
lastAuthCookieDebugState.set(key, state);
|
||||
console.info(`[auth-cookie] ${event}`, { storagePrefix, ...details });
|
||||
}
|
||||
|
||||
function readSecureStoreValueSync(key: string): string | null {
|
||||
const value = SecureStore.getItem(key);
|
||||
@@ -56,25 +68,19 @@ export function getAuthCookie(
|
||||
).getCookie?.();
|
||||
if (fromClient?.trim()) {
|
||||
const cookie = fromClient.trim();
|
||||
if (AUTH_COOKIE_DEBUG) {
|
||||
console.info("[auth-cookie] using client cookie", {
|
||||
storagePrefix,
|
||||
length: cookie.length,
|
||||
names: cookieNames(cookie),
|
||||
});
|
||||
}
|
||||
debugAuthCookie("using client cookie", storagePrefix, {
|
||||
length: cookie.length,
|
||||
names: cookieNames(cookie),
|
||||
});
|
||||
return cookie;
|
||||
}
|
||||
|
||||
const fromPrefix = readStoredCookie(storagePrefix);
|
||||
if (fromPrefix) {
|
||||
if (AUTH_COOKIE_DEBUG) {
|
||||
console.info("[auth-cookie] using stored cookie", {
|
||||
storagePrefix,
|
||||
length: fromPrefix.length,
|
||||
names: cookieNames(fromPrefix),
|
||||
});
|
||||
}
|
||||
debugAuthCookie("using stored cookie", storagePrefix, {
|
||||
length: fromPrefix.length,
|
||||
names: cookieNames(fromPrefix),
|
||||
});
|
||||
return fromPrefix;
|
||||
}
|
||||
|
||||
@@ -82,18 +88,15 @@ export function getAuthCookie(
|
||||
storagePrefix === GUEST_AUTH_STORAGE_PREFIX
|
||||
? null
|
||||
: readStoredCookie(GUEST_AUTH_STORAGE_PREFIX);
|
||||
if (AUTH_COOKIE_DEBUG) {
|
||||
console.info("[auth-cookie] resolved tRPC cookie", {
|
||||
storagePrefix,
|
||||
fallbackPrefix:
|
||||
fromGuest && storagePrefix !== GUEST_AUTH_STORAGE_PREFIX
|
||||
? GUEST_AUTH_STORAGE_PREFIX
|
||||
: null,
|
||||
hasCookie: Boolean(fromGuest),
|
||||
length: fromGuest?.length ?? 0,
|
||||
names: fromGuest ? cookieNames(fromGuest) : [],
|
||||
});
|
||||
}
|
||||
debugAuthCookie("resolved tRPC cookie", storagePrefix, {
|
||||
fallbackPrefix:
|
||||
fromGuest && storagePrefix !== GUEST_AUTH_STORAGE_PREFIX
|
||||
? GUEST_AUTH_STORAGE_PREFIX
|
||||
: null,
|
||||
hasCookie: Boolean(fromGuest),
|
||||
length: fromGuest?.length ?? 0,
|
||||
names: fromGuest ? cookieNames(fromGuest) : [],
|
||||
});
|
||||
return fromGuest;
|
||||
}
|
||||
|
||||
@@ -103,21 +106,16 @@ export function getAuthCookieHeaders(
|
||||
): Record<string, string> {
|
||||
const cookie = getAuthCookie(authClient, storagePrefix);
|
||||
if (!cookie) {
|
||||
if (AUTH_COOKIE_DEBUG) {
|
||||
console.info("[auth-cookie] no tRPC auth cookie", { storagePrefix });
|
||||
}
|
||||
debugAuthCookie("no tRPC auth cookie", storagePrefix, {});
|
||||
return {};
|
||||
}
|
||||
|
||||
const sessionToken = cookie.match(SESSION_TOKEN_COOKIE_PART)?.[1];
|
||||
if (AUTH_COOKIE_DEBUG) {
|
||||
console.info("[auth-cookie] sending tRPC auth headers", {
|
||||
storagePrefix,
|
||||
cookieLength: cookie.length,
|
||||
cookieNames: cookieNames(cookie),
|
||||
hasSessionTokenHeader: Boolean(sessionToken),
|
||||
});
|
||||
}
|
||||
debugAuthCookie("sending tRPC auth headers", storagePrefix, {
|
||||
cookieLength: cookie.length,
|
||||
cookieNames: cookieNames(cookie),
|
||||
hasSessionTokenHeader: Boolean(sessionToken),
|
||||
});
|
||||
return {
|
||||
cookie,
|
||||
Cookie: cookie,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/** Matches web invoice-form default numbering. */
|
||||
export function generateInvoiceNumber(): string {
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
return `INV-${date}-${String(Date.now()).slice(-6)}`;
|
||||
export function generateInvoiceNumber(now = new Date()): string {
|
||||
const date = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0"),
|
||||
].join("");
|
||||
|
||||
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
|
||||
}
|
||||
|
||||
export function defaultDueDate(issueDate: Date): Date {
|
||||
|
||||
@@ -6,9 +6,6 @@ import { spacing } from "@/constants/theme";
|
||||
/** Standard UITabBar content height (home indicator is separate). */
|
||||
const IOS_TAB_BAR_HEIGHT = 49;
|
||||
|
||||
/** Trim extra inset so scroll content sits closer to the tab bar. */
|
||||
const TAB_BAR_PADDING_TRIM = spacing.lg;
|
||||
|
||||
/**
|
||||
* Pixels between the bottom of the safe-area layout frame and the window bottom.
|
||||
*/
|
||||
@@ -39,7 +36,7 @@ export function useTabBarScrollPadding(): number {
|
||||
const tabBar = useNativeTabBarHeight();
|
||||
const clearance = tabBar + homeIndicator;
|
||||
|
||||
return Math.max(spacing.xs, clearance - TAB_BAR_PADDING_TRIM);
|
||||
return clearance + spacing.sm;
|
||||
}
|
||||
|
||||
/** Bottom offset for floating action buttons above the tab bar. */
|
||||
|
||||
Reference in New Issue
Block a user