Compare commits
No commits in common. "e45d92b3336acacc187d90619094d10580f84d84" and "f484465bee29025db52e8a33127370cf08d9a6ad" have entirely different histories.
e45d92b333
...
f484465bee
8 changed files with 0 additions and 396 deletions
|
|
@ -18,17 +18,6 @@
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Custom scheme for the web app's "open the installed app"
|
|
||||||
redirect (see apps/web/src/lib/mobile-app-redirect.ts) —
|
|
||||||
additive, does not touch the launcher filter above. No
|
|
||||||
android:host/android:path restriction, so any
|
|
||||||
batchcooking://... URL matches. -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="batchcooking" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import {
|
|
||||||
APP_OPEN_TIMEOUT_MS,
|
|
||||||
attemptOpenNativeApp,
|
|
||||||
isAndroidPhoneBrowser,
|
|
||||||
STAY_ON_WEB_PREFERENCE_KEY,
|
|
||||||
} from "../../src/lib/mobile-app-redirect";
|
|
||||||
|
|
||||||
// Pure-ish logic (timers/localStorage/document.hidden, no React component to
|
|
||||||
// mount) — same posture as version-compare.cy.tsx: reuses the component-test
|
|
||||||
// runner purely for `expect`/`cy.clock()`, no `cy.mount`. `.cy.tsx` (not
|
|
||||||
// `.cy.ts`) only because that's what cypress.config.ts's component
|
|
||||||
// `specPattern` looks for.
|
|
||||||
|
|
||||||
function setUserAgent(ua: string) {
|
|
||||||
Object.defineProperty(window.navigator, "userAgent", { value: ua, configurable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDocumentHidden(hidden: boolean) {
|
|
||||||
Object.defineProperty(document, "hidden", { value: hidden, configurable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("isAndroidPhoneBrowser", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
setUserAgent(window.navigator.userAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is true for a real Android phone Chrome UA", () => {
|
|
||||||
setUserAgent(
|
|
||||||
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36",
|
|
||||||
);
|
|
||||||
expect(isAndroidPhoneBrowser()).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false for an Android tablet UA (Android without Mobile)", () => {
|
|
||||||
setUserAgent(
|
|
||||||
"Mozilla/5.0 (Linux; Android 14; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
|
|
||||||
);
|
|
||||||
expect(isAndroidPhoneBrowser()).to.be.false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false for a desktop UA", () => {
|
|
||||||
setUserAgent(
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
|
|
||||||
);
|
|
||||||
expect(isAndroidPhoneBrowser()).to.be.false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("attemptOpenNativeApp", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY);
|
|
||||||
setDocumentHidden(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY);
|
|
||||||
setDocumentHidden(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onFallback immediately, with no delay, when the visitor opted to stay on web", () => {
|
|
||||||
localStorage.setItem(STAY_ON_WEB_PREFERENCE_KEY, "true");
|
|
||||||
const onFallback = cy.stub().as("onFallback");
|
|
||||||
|
|
||||||
attemptOpenNativeApp({ onFallback });
|
|
||||||
|
|
||||||
cy.get("@onFallback").should("have.been.calledOnce");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onFallback once the delay elapses if the page never got hidden (app not installed)", () => {
|
|
||||||
cy.clock();
|
|
||||||
const onFallback = cy.stub().as("onFallback");
|
|
||||||
|
|
||||||
// `openAppUrl` stubbed to a no-op — real navigation to the custom
|
|
||||||
// scheme is only meaningful in a real browser with the app installed
|
|
||||||
// or not; here only the timer/visibility logic around it is under
|
|
||||||
// test (see the option's own doc comment in mobile-app-redirect.ts).
|
|
||||||
attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} });
|
|
||||||
cy.get("@onFallback").should("not.have.been.called");
|
|
||||||
|
|
||||||
cy.tick(APP_OPEN_TIMEOUT_MS);
|
|
||||||
cy.get("@onFallback").should("have.been.calledOnce");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never calls onFallback if the page is hidden before the delay elapses (handoff succeeded)", () => {
|
|
||||||
cy.clock();
|
|
||||||
const onFallback = cy.stub().as("onFallback");
|
|
||||||
|
|
||||||
attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} });
|
|
||||||
|
|
||||||
cy.then(() => {
|
|
||||||
setDocumentHidden(true);
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.tick(APP_OPEN_TIMEOUT_MS);
|
|
||||||
cy.get("@onFallback").should("not.have.been.called");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
// Pure layout spec, no Cucumber — a single static page with no user
|
|
||||||
// journey of its own (see layout.cy.ts's identical rationale for this
|
|
||||||
// 3-way test split). No `cy.intercept("**/auth/me", ...)` needed: this
|
|
||||||
// route renders outside RequireAuth entirely (see App.tsx's own doc
|
|
||||||
// comment on why), so it never calls that endpoint.
|
|
||||||
|
|
||||||
describe("DownloadPage — the mobile-app-redirect fallback", () => {
|
|
||||||
it("renders the APK download link, a retry button, and a way back home", () => {
|
|
||||||
cy.visit("/telecharger");
|
|
||||||
|
|
||||||
cy.contains("h1", "Installer l'application Android").should("be.visible");
|
|
||||||
|
|
||||||
// API_BASE_URL is empty locally (relative-URL, same-origin default —
|
|
||||||
// see client.ts), so the real target collapses to this relative path.
|
|
||||||
cy.contains("a", "Télécharger l'APK").should("have.attr", "href", "/mobile/download");
|
|
||||||
|
|
||||||
cy.contains("button", "Réessayer d'ouvrir l'application").should("be.visible");
|
|
||||||
|
|
||||||
cy.contains("a", "Retour à l'accueil").should("have.attr", "href", "/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is reachable without being logged in — no bounce to /login", () => {
|
|
||||||
cy.visit("/telecharger");
|
|
||||||
cy.url().should("include", "/telecharger");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -14,7 +14,6 @@ import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
|
||||||
import { LoginPage } from "./pages/auth/LoginPage";
|
import { LoginPage } from "./pages/auth/LoginPage";
|
||||||
import { SignupPage } from "./pages/auth/SignupPage";
|
import { SignupPage } from "./pages/auth/SignupPage";
|
||||||
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
||||||
import { DownloadPage } from "./pages/download/DownloadPage";
|
|
||||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
||||||
|
|
@ -54,13 +53,6 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
|
||||||
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
|
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
|
||||||
* skipped otherwise, straight to `/onboarding/allergenes`.
|
* skipped otherwise, straight to `/onboarding/allergenes`.
|
||||||
*
|
*
|
||||||
* `/telecharger` is the mobile-app-redirect chantier's fallback landing spot
|
|
||||||
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
|
|
||||||
* `/login`/`/signup`, but deliberately *not* wrapped in
|
|
||||||
* `RedirectIfAuthenticated`: a signed-in visitor still has a legitimate
|
|
||||||
* reason to land here (logged in on the web, app just not installed on
|
|
||||||
* this particular device yet).
|
|
||||||
*
|
|
||||||
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
|
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
|
||||||
* triage, off-catalog ingredient review). It's its own top-level group,
|
* triage, off-catalog ingredient review). It's its own top-level group,
|
||||||
* wrapped in {@link AdminAuthProvider} so the `GET /admin/api/auth/me` session
|
* wrapped in {@link AdminAuthProvider} so the `GET /admin/api/auth/me` session
|
||||||
|
|
@ -148,7 +140,6 @@ export function App() {
|
||||||
</RedirectIfAuthenticated>
|
</RedirectIfAuthenticated>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/telecharger" element={<DownloadPage />} />
|
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<AdminAuthProvider>
|
<AdminAuthProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
/**
|
|
||||||
* The Android app's own custom URL scheme — registered as an *additional*,
|
|
||||||
* additive `<intent-filter>` in `android/app/src/main/AndroidManifest.xml`
|
|
||||||
* (the pre-existing `MAIN`/`LAUNCHER` filter is untouched). Attempting to
|
|
||||||
* navigate here from a browser hands off to the installed app; on a device
|
|
||||||
* without it installed, an unrecognized custom scheme is a safe no-op in
|
|
||||||
* every browser this app targets (Android Chrome/WebView) — the current
|
|
||||||
* page just keeps running, nothing throws, nothing navigates away.
|
|
||||||
*/
|
|
||||||
export const APP_OPEN_URL = "batchcooking://open";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `localStorage` key for the "toujours ouvrir dans l'appli / rester sur le
|
|
||||||
* web" preference (UI for it lands in a later ticket of the mobile/tablet
|
|
||||||
* UI chantier, `/parametres/application`) — exported so that settings page
|
|
||||||
* can read/write the exact same key rather than this module and that page
|
|
||||||
* silently drifting to two different strings. Absent (the default) means
|
|
||||||
* "always try to open the app" — {@link attemptOpenNativeApp} never
|
|
||||||
* refuses to try just because nothing has set this yet.
|
|
||||||
*/
|
|
||||||
export const STAY_ON_WEB_PREFERENCE_KEY = "batchcooking:stayOnWeb";
|
|
||||||
|
|
||||||
/** How long to wait for the OS to hand off to the app before assuming it isn't installed. Exported for tests, not meant to be tuned per call site. */
|
|
||||||
export const APP_OPEN_TIMEOUT_MS = 1500;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "smart app banner" pattern: try to open the installed Android app via
|
|
||||||
* its custom scheme, and call `onFallback` if that doesn't visibly succeed
|
|
||||||
* within a short delay — the one signal available from a plain web page
|
|
||||||
* that a scheme navigation "worked" is that the OS backgrounds/replaces the
|
|
||||||
* tab (`document.hidden` flips `true`) to hand off to the other app; if the
|
|
||||||
* page is still the foreground document once the timer fires, nothing
|
|
||||||
* caught the scheme, so there's no app installed to open.
|
|
||||||
*
|
|
||||||
* Respects {@link STAY_ON_WEB_PREFERENCE_KEY}: when a visitor has
|
|
||||||
* explicitly asked to stay on the web, this skips the scheme attempt
|
|
||||||
* entirely and calls `onFallback` immediately — never a forced redirect a
|
|
||||||
* user has opted out of.
|
|
||||||
*
|
|
||||||
* `onFallback`'s job (typically: navigate to `/telecharger`) is entirely
|
|
||||||
* the caller's — this module has no opinion on routing.
|
|
||||||
*/
|
|
||||||
export function attemptOpenNativeApp({
|
|
||||||
onFallback,
|
|
||||||
delayMs = APP_OPEN_TIMEOUT_MS,
|
|
||||||
openAppUrl = navigateToAppScheme,
|
|
||||||
}: {
|
|
||||||
onFallback: () => void;
|
|
||||||
/** Override for tests — production callers should always use the default. */
|
|
||||||
delayMs?: number;
|
|
||||||
/**
|
|
||||||
* Override for tests only — production callers should never pass this.
|
|
||||||
* Real navigation to an unrecognized custom scheme is a safe no-op in
|
|
||||||
* every browser this app targets, but it's still a real, uncontrolled
|
|
||||||
* side effect a test shouldn't actually trigger; every other behavior
|
|
||||||
* here (the timer, the visibility check, the preference short-circuit)
|
|
||||||
* is exercised the same way regardless of what this does.
|
|
||||||
*/
|
|
||||||
openAppUrl?: () => void;
|
|
||||||
}): void {
|
|
||||||
if (localStorage.getItem(STAY_ON_WEB_PREFERENCE_KEY) === "true") {
|
|
||||||
onFallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
if (!document.hidden) onFallback();
|
|
||||||
}, delayMs);
|
|
||||||
|
|
||||||
// If the handoff actually happens, this tab is about to be backgrounded
|
|
||||||
// — clear the pending fallback the moment that's detected so it can
|
|
||||||
// never fire later (e.g. once the visitor switches back to this tab
|
|
||||||
// after using the app), and stop listening either way once it has fired
|
|
||||||
// once.
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
|
|
||||||
function handleVisibilityChange() {
|
|
||||||
if (!document.hidden) return;
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
openAppUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToAppScheme(): void {
|
|
||||||
window.location.href = APP_OPEN_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the current visitor is on an Android phone's browser — the one
|
|
||||||
* case {@link attemptOpenNativeApp} is worth trying at all, since the app
|
|
||||||
* only exists for Android (see the root README) and only in a phone form
|
|
||||||
* factor makes sense to redirect (a tablet browsing the site is a
|
|
||||||
* perfectly normal experience on its own, no app-open prompt needed).
|
|
||||||
*
|
|
||||||
* User-agent based, deliberately **not** {@link useIsMobileViewport} — that
|
|
||||||
* hook answers "is this viewport narrow" (a layout question, true for a
|
|
||||||
* resized desktop browser window too), this answers "is this genuinely an
|
|
||||||
* Android phone" (a capability question). Android's own UA convention
|
|
||||||
* drops "Mobile" from a *tablet's* UA string while keeping "Android" — the
|
|
||||||
* combination of both tokens is what actually narrows this to a phone.
|
|
||||||
*/
|
|
||||||
export function isAndroidPhoneBrowser(): boolean {
|
|
||||||
if (typeof navigator === "undefined") return false;
|
|
||||||
return /Android/.test(navigator.userAgent) && /Mobile/.test(navigator.userAgent);
|
|
||||||
}
|
|
||||||
|
|
@ -1365,13 +1365,6 @@
|
||||||
"button": "Créer un compte"
|
"button": "Créer un compte"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"download": {
|
|
||||||
"title": "Installer l'application Android",
|
|
||||||
"lede": "Téléchargez le fichier .apk, ou réessayez d'ouvrir l'application si elle est déjà installée sur cet appareil.",
|
|
||||||
"downloadButton": "Télécharger l'APK",
|
|
||||||
"retryButton": "Réessayer d'ouvrir l'application",
|
|
||||||
"backLink": "Retour à l'accueil"
|
|
||||||
},
|
|
||||||
"tour": {
|
"tour": {
|
||||||
"controls": {
|
"controls": {
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { API_BASE_URL } from "../../api/client";
|
|
||||||
import { attemptOpenNativeApp } from "../../lib/mobile-app-redirect";
|
|
||||||
import "./download-page.scss";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fallback landing spot for {@link attemptOpenNativeApp} — reached when
|
|
||||||
* a visitor's phone didn't have the app installed to hand off to (or,
|
|
||||||
* directly, by anyone who just wants the APK — a plain link works
|
|
||||||
* regardless of how they arrived). Public, no `RequireAuth`/
|
|
||||||
* `RedirectIfAuthenticated` gate: unlike `/login`/`/signup`, a signed-in
|
|
||||||
* visitor still has a legitimate reason to land here (they're logged in on
|
|
||||||
* the web but haven't installed the app on *this* device yet).
|
|
||||||
*
|
|
||||||
* The actual download reuses `${API_BASE_URL}/mobile/download` —
|
|
||||||
* `apps/api/src/modules/mobile/`'s existing same-origin proxy in front of
|
|
||||||
* Forgejo's release asset (see that module's own doc comment for why a
|
|
||||||
* direct Forgejo link doesn't work: no CORS headers, no GitHub-style
|
|
||||||
* `/releases/latest/download/<file>` shortcut). Nothing new needed there.
|
|
||||||
*/
|
|
||||||
export function DownloadPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="download-page">
|
|
||||||
<div className="download-page__card">
|
|
||||||
<h1>{t("download.title")}</h1>
|
|
||||||
<p className="download-page__lede">{t("download.lede")}</p>
|
|
||||||
|
|
||||||
<a className="download-page__cta" href={`${API_BASE_URL}/mobile/download`}>
|
|
||||||
{t("download.downloadButton")}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="download-page__retry"
|
|
||||||
// Already on the fallback page — a failed retry needs no further
|
|
||||||
// fallback of its own, the download button right above already
|
|
||||||
// covers it.
|
|
||||||
onClick={() => attemptOpenNativeApp({ onFallback: () => {} })}
|
|
||||||
>
|
|
||||||
{t("download.retryButton")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="download-page__hint">{t("landing.hero.mobileHint")}</p>
|
|
||||||
|
|
||||||
<Link className="download-page__back" to="/">
|
|
||||||
{t("download.backLink")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
// DownloadPage — colocated with DownloadPage.tsx. A small centered card,
|
|
||||||
// same "full-viewport centering wrapper + raised card" language as
|
|
||||||
// features/auth/auth-form.scss's `.auth-page`/`.auth-card` (this page sits
|
|
||||||
// in the same "not logged in yet, or not on this device yet" context), but
|
|
||||||
// its own classes rather than reused ones — no real behavior in common,
|
|
||||||
// just a similar look.
|
|
||||||
|
|
||||||
.download-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-page__card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
width: 100%;
|
|
||||||
max-width: var(--max-width-form);
|
|
||||||
padding: var(--space-xl);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
color: var(--color-text);
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-page__lede {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-page__cta {
|
|
||||||
align-self: stretch;
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-page__retry {
|
|
||||||
align-self: stretch;
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-page__hint {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-page__back {
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue