diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 01e91df1..e0261b6b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -5,14 +5,18 @@ SHELL ["/bin/sh", "-o", "pipefail", "-c"] RUN apk add --no-cache \ bash \ build-base \ + chromium \ curl \ git \ + harfbuzz \ libxml2-dev \ libxslt-dev \ nodejs \ + nss \ npm \ openssl-dev \ python3 \ + ttf-freefont \ yaml-dev \ tzdata diff --git a/AGENTS.md b/AGENTS.md index 5dae9788..f4849377 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ This document defines execution constraints for AI agents. For general contribut ## Agent-Specific Verification Rules - Always run Dev Container smoke + `make ready` for changes. +- For frontend changes or API contract/spec changes, run `make ci-ready` to mirror CI parity checks. - For frontend changes, also verify in `chrome-devtools` MCP at `http://127.0.0.1:4001/` while the Dev Container is running. - Capture a quick state check for all affected UI states (e.g., guest/member/result) to enforce state parity and avoid duplicate actions. diff --git a/Makefile b/Makefile index 130f89c2..e2bae4ac 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ # frozen_string_literal: true -.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e +.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready ci-ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e + +RUBOCOP_FLAGS ?= --cache false # Default target help: ## Show this help message @@ -60,7 +62,7 @@ lint: lint-ruby lint-js ## Run all linters (Ruby + Frontend) - errors when issue lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found @echo "Running RuboCop linting..." - bundle exec rubocop + bundle exec rubocop $(RUBOCOP_FLAGS) @echo "Running Zeitwerk eager-load check..." bundle exec rake zeitwerk:verify @echo "Running YARD public-method docs check..." @@ -105,6 +107,13 @@ ready: ## Pre-commit gate (quick checks + RSpec) bundle exec rspec @echo "Pre-commit checks complete!" +ci-ready: ## CI parity gate (ready + OpenAPI verify + frontend e2e smoke) + @echo "Running CI parity checks..." + $(MAKE) ready + $(MAKE) openapi-verify + $(MAKE) test-frontend-e2e + @echo "CI parity checks complete!" + yard-verify-public-docs: ## Verify essential YARD docs for all public methods in app/ bundle exec rake yard:verify_public_docs diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index b95e67ff..8a7afd00 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -26,45 +26,112 @@ test.describe('frontend smoke', () => { }); }); - await page.route(/\/api\/v1\/strategies$/, async (route) => { + await page.goto('/'); + + await expect(page.getByLabel('Page URL')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); + + await page.getByLabel('Page URL').fill('https://example.com/articles'); + await page.getByRole('button', { name: 'Generate feed URL' }).click(); + + await expect(page.getByRole('heading', { name: 'Enter access token' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Access token' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save and continue' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Back' })).toBeVisible(); + + await page.getByRole('button', { name: 'Back' }).click(); + await expect(page).toHaveURL(/#\/create(?:\?.*)?$/); + await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); + await expect(page.locator('.form-shell')).toHaveAttribute('data-state', 'create'); + await expect(page.getByLabel('Utilities')).toBeVisible(); + }); + + test('shows result after successful feed creation without snapshot recovery', async ({ page }) => { + await page.route(/\/api\/v1$/, async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { - strategies: [ - { id: 'faraday', name: 'faraday', display_name: 'Default' }, - { - id: 'browserless', - name: 'browserless', - display_name: 'JavaScript pages (recommended)', + api: { + name: 'html2rss-web API', + description: 'RESTful API for converting websites to RSS feeds', + openapi_url: 'http://example.test/openapi.yaml', + }, + instance: { + feed_creation: { + enabled: true, + access_token_required: true, }, - ], + featured_feeds: [], + }, }, - meta: { total: 2 }, }), }); }); - await page.goto('/'); + await page.route(/\/api\/v1\/feeds$/, async (route) => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + created_at: '2026-04-05T08:59:00.000Z', + updated_at: '2026-04-05T09:00:00.000Z', + }, + }, + }), + }); + }); - await expect(page.getByLabel('Page URL')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); - await expect(page.getByLabel('Utilities')).toBeVisible(); - await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible(); + await page.route(/\/api\/v1\/feeds\/generated-token\.json$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/feed+json', + body: JSON.stringify({ + items: [ + { + title: 'Sample preview item', + content_text: 'Current preview fetch includes rendered content.', + date_published: '2026-04-05T09:00:00.000Z', + url: 'https://example.com/articles/sample-preview-item', + }, + ], + }), + }); + }); + + await page.addInitScript(() => { + sessionStorage.setItem('html2rss_access_token', 'token-123'); + }); + await page.goto('/'); await page.getByLabel('Page URL').fill('https://example.com/articles'); await page.getByRole('button', { name: 'Generate feed URL' }).click(); - await expect(page.getByRole('heading', { name: 'Enter access token' })).toBeVisible(); - await expect(page.getByRole('textbox', { name: 'Access token' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Save and continue' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Back' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Feed ready' })).toBeVisible(); + await expect(page.locator('.result-shell')).toHaveAttribute('data-state', 'result'); + await expect(page.getByText('Example Feed')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open feed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open JSON Feed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open in feed reader' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Create another feed' })).toBeVisible(); + await expect(page.getByText('Sample preview item')).toBeVisible(); + await expect(page.getByText('Current preview fetch includes rendered content.')).toBeVisible(); - await page.getByRole('button', { name: 'Back' }).click(); - await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); - await expect(page.getByLabel('Utilities')).toBeVisible(); - await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible(); + await page.goto('/#/result/missing-token'); + + await expect(page.getByLabel('Page URL')).toBeVisible(); + await expect(page.getByText('Saved result unavailable')).toHaveCount(0); + await expect(page.locator('.result-recovery')).toHaveCount(0); }); }); diff --git a/frontend/package.json b/frontend/package.json index fd2311f8..c510ffe4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "stylelint-config-standard": "^40.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.59.1", - "vite": "^6.4.2", + "vite": "^7.3.2", "vitest": "^3.2.4" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index ef9cbefb..81da0b8e 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,6 +1,18 @@ +import { existsSync } from 'node:fs'; import { defineConfig, devices } from '@playwright/test'; -const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; +const chromiumExecutablePath = + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || resolveChromiumExecutablePath(); + +function resolveChromiumExecutablePath(): string | undefined { + const candidates = [ + '/usr/bin/chromium-browser', + '/usr/bin/chromium', + '/home/vscode/.cache/ms-playwright/chromium-1217/chrome-linux/chrome', + ]; + + return candidates.find((candidate) => existsSync(candidate)); +} export default defineConfig({ testDir: './e2e', diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 946bd010..bea2dbe2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: version: 1.59.1 '@preact/preset-vite': specifier: ^2.10.5 - version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.2)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)) + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -73,8 +73,8 @@ importers: specifier: ^8.59.1 version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vite: - specifier: ^6.4.2 - version: 6.4.2(@types/node@25.6.0)(jiti@2.6.1) + specifier: ^7.3.2 + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1) vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.2(@types/node@25.6.0)(typescript@5.9.3)) @@ -254,158 +254,158 @@ packages: peerDependencies: postcss-selector-parser: ^7.1.1 - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1290,8 +1290,8 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true @@ -2470,19 +2470,19 @@ packages: peerDependencies: vite: 5.x || 6.x || 7.x || 8.x - vite@6.4.2: - resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@types/node': ^20.19.0 || >=22.12.0 jiti: '>=1.21.0' - less: '*' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 @@ -2845,82 +2845,82 @@ snapshots: dependencies: postcss-selector-parser: 7.1.1 - '@esbuild/aix-ppc64@0.25.12': + '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/android-arm64@0.25.12': + '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm@0.25.12': + '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-x64@0.25.12': + '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-x64@0.25.12': + '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.25.12': + '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/linux-arm64@0.25.12': + '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm@0.25.12': + '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-ia32@0.25.12': + '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-loong64@0.25.12': + '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-mips64el@0.25.12': + '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-ppc64@0.25.12': + '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.25.12': + '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-s390x@0.25.12': + '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-x64@0.25.12': + '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.25.12': + '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.25.12': + '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/sunos-x64@0.25.12': + '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/win32-arm64@0.25.12': + '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-ia32@0.25.12': + '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-x64@0.25.12': + '@esbuild/win32-x64@0.27.7': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': @@ -3125,19 +3125,19 @@ snapshots: dependencies: playwright: 1.59.1 - '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.2)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1))': + '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) - '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)) + '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)) '@rollup/pluginutils': 5.3.0(rollup@4.60.2) babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0) debug: 4.4.3 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1) - vite-prerender-plugin: 0.5.13(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1) + vite-prerender-plugin: 0.5.13(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)) zimmerframe: 1.1.4 transitivePeerDependencies: - preact @@ -3152,7 +3152,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.12(preact@10.29.1)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1))': + '@prefresh/vite@2.4.12(preact@10.29.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1))': dependencies: '@babel/core': 7.29.0 '@prefresh/babel-plugin': 0.5.3 @@ -3160,7 +3160,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.29.1 - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1) transitivePeerDependencies: - supports-color @@ -3403,14 +3403,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.14.2(@types/node@25.6.0)(typescript@5.9.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1))': + '@vitest/mocker@3.2.4(msw@2.14.2(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.2(@types/node@25.6.0)(typescript@5.9.3) - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -3817,34 +3817,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.25.12: + esbuild@0.27.7: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 escalade@3.2.0: {} @@ -5038,7 +5038,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1) transitivePeerDependencies: - '@types/node' - jiti @@ -5053,7 +5053,7 @@ snapshots: - tsx - yaml - vite-prerender-plugin@0.5.13(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)): + vite-prerender-plugin@0.5.13(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -5061,11 +5061,11 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0-pre2 - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1) - vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1): + vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1): dependencies: - esbuild: 0.25.12 + esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.12 @@ -5080,7 +5080,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.14.2(@types/node@25.6.0)(typescript@5.9.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)) + '@vitest/mocker': 3.2.4(msw@2.14.2(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -5098,7 +5098,7 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1) vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index cdfeed17..03328ad8 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -3,16 +3,22 @@ } .page-shell { + --footer-nav-reserve: calc(5.25rem + env(safe-area-inset-bottom, 0px)); + min-height: 100vh; display: grid; grid-template-rows: minmax(0, 1fr) auto; } .page-main { + min-height: 100%; + display: flex; + flex-direction: column; width: 100%; max-width: var(--layout-page-max-width); margin: 0 auto; - padding: clamp(0.85rem, 3vh, 2rem) clamp(var(--space-3), 3vw, var(--space-4)) var(--space-5); + padding: clamp(0.85rem, 3vh, 2rem) clamp(var(--space-3), 3vw, var(--space-4)) + calc(var(--space-5) + var(--footer-nav-reserve)); } /* Layout Engine */ @@ -51,6 +57,14 @@ justify-items: center; } +.workspace-content { + width: 100%; + display: grid; + gap: var(--space-4); + align-content: center; + min-height: clamp(20rem, 52vh, 36rem); +} + .workspace-hero { gap: var(--space-1); text-align: center; @@ -76,10 +90,11 @@ } .result-shell { - border: var(--border-width) solid var(--state-frame-border); - border-radius: var(--state-frame-radius); - background: var(--state-frame-bg); - box-shadow: var(--state-frame-shadow); + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; gap: var(--section-gap); } @@ -180,6 +195,10 @@ color: var(--danger); } +.field-help--warning { + color: var(--text-body); +} + .input { width: 100%; min-width: 0; @@ -496,6 +515,35 @@ a:focus-visible { text-align: left; } +.result-status-note { + width: fit-content; + max-width: min(100%, var(--layout-rail-copy)); + margin: 0; + padding: var(--space-2) var(--space-3); + border: var(--border-width) solid var(--state-frame-border); + border-radius: var(--radius-md); + background: rgb(var(--color-rgb-white) / 3%); + color: var(--text-soft); + text-align: left; + font-size: var(--font-size-00); + line-height: 1.35; +} + +.result-recovery { + width: min(100%, var(--layout-rail-reading)); + max-width: none; + margin: 0 auto; + padding: clamp(var(--space-4), 5vw, var(--space-6)); + border-color: var(--state-frame-border); + border-radius: var(--radius-lg); + background: + linear-gradient(180deg, rgb(var(--color-rgb-white) / 4%), transparent 68%), + rgb(var(--color-rgb-white) / 2%); + box-shadow: var(--state-frame-shadow); + justify-items: start; + text-align: left; +} + .result-preview { justify-items: start; padding-top: var(--space-4); @@ -515,8 +563,6 @@ a:focus-visible { padding: 0; width: 100%; list-style: none; - display: grid; - gap: 1rem; } .preview-card { @@ -527,6 +573,7 @@ a:focus-visible { linear-gradient(180deg, rgb(var(--color-rgb-white) / 4%), transparent 80%), rgb(var(--color-rgb-white) / 2%); box-shadow: inset 0 1px 0 rgb(var(--color-rgb-white) / 4%); + margin-bottom: 1rem; } .preview-card__title { @@ -550,6 +597,8 @@ a:focus-visible { margin: 0; color: var(--text-body); font-size: var(--font-size-0); + text-overflow: ellipsis; + overflow: hidden; } .preview-card__actions { @@ -609,14 +658,17 @@ a:focus-visible { } .utility-strip { - gap: var(--space-2); width: 100%; - max-width: var(--layout-field-max-width); + max-width: var(--layout-page-max-width); } .utility-strip__items { + display: grid; + grid-auto-flow: column; + grid-auto-columns: max-content; + align-items: center; + justify-content: center; gap: var(--space-2); - justify-items: center; } .utility-link, @@ -646,18 +698,28 @@ a:focus-visible { text-transform: uppercase; } -@media (width >= 48rem) { - .page-main { - padding-top: clamp(1rem, 6vh, 4rem); - } +.app-footer { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 20; + display: grid; + justify-items: center; + padding: var(--space-2) var(--space-3) calc(var(--space-2) + env(safe-area-inset-bottom, 0px)); + background: linear-gradient(180deg, transparent 0%, rgb(var(--color-rgb-black) / 68%) 56%); + backdrop-filter: blur(4px); +} - .utility-strip__items { - grid-auto-flow: column; - gap: var(--space-4); - } +.app-footer__inner { + width: 100%; + max-width: var(--layout-page-max-width); } @media (width < 48rem) { + .page-shell { + --footer-nav-reserve: 0px; + } + .page-main, .workspace-shell { width: 100%; @@ -669,15 +731,33 @@ a:focus-visible { gap: var(--space-4); } - .form-shell--minimal, - .result-shell { + .workspace-content { + min-height: clamp(16rem, 42vh, 26rem); + } + + .form-shell--minimal { padding: var(--space-4); border-radius: var(--radius-md); } + .result-shell { + padding: 0; + border-radius: 0; + } + + .result-status-note { + width: 100%; + } + + .result-recovery { + width: 100%; + padding: var(--space-4); + } + .input--lg { min-height: 4rem; - padding-right: calc(var(--space-7) + var(--space-4)); + + /* padding-right: calc(var(--space-7) + var(--space-4)); */ } .result-hero, @@ -717,6 +797,32 @@ a:focus-visible { min-width: 4rem; padding-inline: var(--space-3); } + + .app-footer { + position: static; + inset-inline: auto; + bottom: auto; + z-index: auto; + margin-top: auto; + padding: var(--space-5) var(--space-3) calc(var(--space-3) + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--border-subtle); + background: transparent; + backdrop-filter: none; + } + + .utility-strip__items { + grid-auto-flow: row; + grid-auto-columns: 1fr; + justify-items: center; + gap: calc(var(--space-1) * 0.9); + } + + .utility-link, + .utility-button { + color: var(--text-faint); + font-size: var(--font-size-00); + opacity: 0.92; + } } @keyframes spin { diff --git a/public/shared-ui.css b/public/shared-ui.css index a17b0206..a1dcd12a 100644 --- a/public/shared-ui.css +++ b/public/shared-ui.css @@ -102,12 +102,6 @@ body { margin: 0; min-width: 20rem; color: var(--text-body); - font-family: var(--font-family-ui); - font-size: var(--font-size-0); - line-height: var(--line-height-base); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizelegibility; background: transparent; } @@ -116,7 +110,12 @@ button, input, select, textarea { - font: inherit; + font-family: var(--font-family-ui); + font-size: var(--font-size-0); + line-height: var(--line-height-base); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizelegibility; } a {