web: refonte design — étape 1 (tokens, composants unifiés, home, docs, playground, status, légal, 404, prerender, e2e)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
81 changed files +2,909 −2,351
modified
hfmarketdata/web/README.md
+124 −73
@@ -1,8 +1,8 @@ | ||
| 1 | 1 | # hfmarketdata/web — site, docs, playground, dashboard |
| 2 | 2 | |
| 3 | 3 | Vite 6 + React 18 + react-router 6. Dark by default, light theme persisted. No CSS framework, no external |
| 4 | −requests at runtime (system font stacks, inline SVG, all JS bundled). Served by the FastAPI app from `dist/` | |
| 5 | −(SPA fallback in `api/main.py`), or by `vite preview` for E2E. | |
| 4 | +requests at runtime (system sans stack, self-hosted JetBrains Mono, inline SVG, all JS bundled). Served by the FastAPI | |
| 5 | +app from `dist/` (SPA fallback in `api/main.py`), or by `vite preview` for E2E. | |
| 6 | 6 | |
| 7 | 7 | ``` |
| 8 | 8 | npm install |
@@ -10,57 +10,103 @@ npm run dev # http://localhost:5173 — proxies /v1, /health, /openapi. | ||
| 10 | 10 | npm run build # vite build + scripts/prerender.mjs → dist/ |
| 11 | 11 | npm run test:e2e # Playwright against `vite preview` (build first) |
| 12 | 12 | npm run examples # refresh content/examples/*.json + openapi snapshot from production |
| 13 | +node scripts/make-brand-assets.mjs # regenerate public/og.png + PNG icons (Playwright) | |
| 13 | 14 | ``` |
| 14 | 15 | |
| 16 | +## Rule: the site never spends the visitor's quota by itself | |
| 17 | + | |
| 18 | +Keyless access is 30 requests / hour per IP. The homepage makes **one** call on load (`GET /v1/status`, quota-exempt) | |
| 19 | +and falls back silently to `content/examples/status_v1_status_get.json`. Docs pages show the captured example and | |
| 20 | +only fetch on an explicit **Run live**. The playground's empty state is the captured example of the selected | |
| 21 | +endpoint; only **Run** (or a valid deep link, once) hits the API. A 429 is always shown as *rate limited — not down*, | |
| 22 | +with a countdown and a free-account CTA (`ErrorState`). | |
| 23 | + | |
| 15 | 24 | ## Architecture |
| 16 | 25 | |
| 17 | 26 | ``` |
| 18 | −index.html theme applied before first paint (inline script reads localStorage hfmd.theme) | |
| 27 | +index.html theme applied before first paint (inline script reads localStorage hfmd.theme); og.png, icons, manifest | |
| 19 | 28 | src/main.jsx BrowserRouter › AuthProvider › LangProvider › App |
| 20 | 29 | src/App.jsx route table, one lazy chunk per area (owner comments inside) |
| 21 | −src/app/ Layout (topbar · drawer · search · footer), theme.css (tokens), theme.js, api.js, auth.jsx | |
| 30 | +src/app/ Layout (topbar · drawer · search · footer), theme.css (design tokens + utilities), theme.js, api.js, auth.jsx | |
| 22 | 31 | src/components/ shared UI (below) + components.css |
| 23 | 32 | src/docs/ docs engine: spec.js (OpenAPI loader), enrich.js (example values), snippets.js (code gen), |
| 24 | 33 | guides.js (registry), search.js (index), mdx-components.jsx, useTitle.js |
| 25 | 34 | src/pages/home|limits|status marketing / limits / live status pages (web-core) |
| 26 | −src/pages/docs DocsLayout (3 columns), DocsHome, ReferenceIndex, OperationPage, GuidePage, ErrorsPage | |
| 35 | +src/pages/legal /terms, /privacy, /data-license (MDX in the same folder) | |
| 36 | +src/pages/NotFound.jsx 404 with search + links | |
| 37 | +src/pages/docs DocsLayout (nav · content · panel), DocsHome, ReferenceIndex, OperationPage, GuidePage, ErrorsPage | |
| 27 | 38 | src/pages/playground|auth|dashboard|admin web-app agent · src/pages/integrations mcp agent |
| 39 | +src/playground/ Playground (app), EndpointPicker (combobox), RequestForm, catalog.js, codegen.js, views/ | |
| 28 | 40 | content/guides/*.mdx guides (MDX) · content/changelog.mdx |
| 29 | 41 | content/examples/<opId>.json captured real responses (scripts/fetch-examples.mjs) — commit them |
| 42 | +content/examples/home-aapl-daily.json one year of daily AAPL bars for the homepage chart (compact rows) | |
| 30 | 43 | content/openapi.snapshot.json bundled spec fallback when /openapi.json is unreachable (e.g. vite preview) |
| 31 | −content/samples/aapl-pe.json homepage chart fallback (scripts/make-aapl-pe-sample.mjs) | |
| 32 | −scripts/ prerender.mjs · fetch-examples.mjs · make-aapl-pe-sample.mjs · bench-formats.mjs · bench-parquet.py | |
| 33 | −e2e/ Playwright specs (site, docs, mobile) · playwright.config.js | |
| 34 | −public/ favicon.svg, logo.svg, og.svg, robots.txt (copied verbatim to dist/) | |
| 44 | +content/samples/aapl-pe.json homepage fundamentals chart (scripts/make-aapl-pe-sample.mjs) | |
| 45 | +public/ favicon.svg, logo.svg, og.png, icon-{32,192,512}.png, apple-touch-icon.png, manifest.webmanifest, | |
| 46 | + fonts/jetbrains-mono-latin.woff2 (31 KB, variable weight), robots.txt, downloads/ | |
| 47 | +scripts/ prerender.mjs · fetch-examples.mjs · make-aapl-pe-sample.mjs · make-brand-assets.mjs · bench-* | |
| 48 | +e2e/ Playwright specs (site, docs, mobile, playground, auth, dashboard) · playwright.config.js | |
| 35 | 49 | ``` |
| 36 | 50 | |
| 37 | −Chunks (`vite.config.js › manualChunks`): `vendor` (react, router), `prism`, `charts` (lightweight-charts, only | |
| 38 | −loaded by the homepage chart / playground), `mdx`, one chunk per page and per guide. | |
| 51 | +Chunks (`vite.config.js › manualChunks`): `vendor` (react, router), `prism` (lazy — `CodeHighlight.jsx` is loaded on | |
| 52 | +demand by `CodeBlock`, so a page paints its code as plain text first), `charts` (lightweight-charts, lazy: homepage | |
| 53 | +charts load when scrolled into view, playground Chart tab), `mdx`, one chunk per page, guide and captured example. | |
| 54 | + | |
| 55 | +## Design system (`src/app/theme.css`) | |
| 39 | 56 | |
| 40 | −### Theme | |
| 57 | +Tokens live in `:root` (dark) with `[data-theme="light"]` overrides. Breakpoints: **640 / 960 / 1200 px**. | |
| 41 | 58 | |
| 42 | −Tokens live in `src/app/theme.css` (`:root` dark, `[data-theme="light"]` overrides). `src/app/theme.js` exposes | |
| 43 | −`useTheme()` → `{ theme, toggle, set }` and `usePersisted(key, initial)` (localStorage-backed `useState`). Colours for | |
| 44 | −code blocks are CSS variables too (`--code-*`) so Prism follows the theme without a second stylesheet. HTTP verb colours: | |
| 45 | −`--get --post --put --patch --delete`. | |
| 59 | +| Group | Tokens | | |
| 60 | +| --- | --- | | |
| 61 | +| Surfaces | `--bg --bg-1 --bg-2 --bg-3`, lines `--line --line-2 --line-3` | | |
| 62 | +| Text | `--fg --fg-1 --fg-2 --fg-3` (all ≥ 4.5:1 on `--bg`/`--bg-1`/`--bg-2`), `--fg-muted-deco` (decorative only, never for text) | | |
| 63 | +| Accent | `--accent --accent-ink --accent-soft --accent-strong`, `--accent-2 --accent-2-soft` | | |
| 64 | +| Market | `--up --down --neutral` (candles, deltas — never raw hex in charts) | | |
| 65 | +| States | `--warn --danger --info --success` + `*-soft` | | |
| 66 | +| Verbs | `--get --post --put --patch --delete` | | |
| 67 | +| Type | `--sans`, `--mono` (JetBrains Mono self-hosted), `--fs-0…8` = 12/13/14/15/17/20/24/32/44, `--lh-tight/snug/body/prose` | | |
| 68 | +| Space / radius / shadow | `--sp-1…10` (4·n), `--r-sm --r-md --r-lg` (6/10/16; `--radius`/`--radius-lg` are legacy aliases), `--shadow-1 --shadow-2` | | |
| 69 | +| Layers | `--z-sticky 10 · --z-drawer 60 · --z-modal 80` | | |
| 70 | +| Controls | `--tap` = 36 px desktop, 44 px under 960 px — every button/link/input uses it as min-height | | |
| 71 | + | |
| 72 | +Utility classes (usable in any page, including the dashboard): `.btn .btn-primary .btn-secondary .btn-ghost .btn-danger | |
| 73 | +.btn-sm .btn-lg .btn-block`, `.icon-btn`, `.card .card-sm`, `.grid .grid-2/3/4`, `.stack .row`, `.eyebrow .lead .muted | |
| 74 | +.small .mono .num .up .down .neutral`, `.table-wrap` (+ `.dense`, `.sticky-first`, `.table-stack` for label/value rows | |
| 75 | +under 640 px), **`.tabs-nav`** (horizontal scrolling tab bar for links/buttons, `.active` or `aria-current`), | |
| 76 | +**`.stat .stat-label .stat-value .stat-hint .stat-delta`**, **`.skeleton .skeleton-block .skeleton-text`**, | |
| 77 | +**`.empty-state`**, **`.error-state`** (+ `.is-429`, `.countdown`), `.sr-only`, `.skip-link`. | |
| 78 | + | |
| 79 | +Accessibility: single `:focus-visible` ring (no double ring on inputs), `prefers-reduced-motion`, skip link, roles on | |
| 80 | +tabs / dialog / combobox / listbox, contrast AA on every text token. | |
| 46 | 81 | |
| 47 | 82 | ## Shared components (`src/components/`, barrel `index.js`) |
| 48 | 83 | |
| 49 | 84 | | Component | Props | Notes | |
| 50 | 85 | | --- | --- | --- | |
| 51 | −| `Code` | `code, language='text', title?, copy=true, maxHeight?, lineNumbers=false` | Prism (prism-react-renderer) + bundled `bash`/`r` grammars added in `Code.jsx`; copy button; scrolls inside when `maxHeight` set. `CopyButton({ text })`, `useCopy()` exported. | | |
| 52 | −| `CodeTabs` | `snippets, title?, maxHeight?` | `snippets` = `{ curl, python, javascript, r }` (any subset) **or** `[{ id, label, language, code }]`. Uses the global persisted language (`hfmd.lang`). | | |
| 53 | −| `Tabs` | `items:[{id,label,content?,icon?}], value?, onChange?, defaultValue?, size='md'|'sm', children?(activeId)` | Accessible tablist (roving focus, arrows/Home/End). | | |
| 54 | −| `Callout` | `type='info'|'tip'|'warning'|'danger', title?` | Aside with icon; `role=alert` for warning/danger. | | |
| 55 | −| `Table` | `columns:[{key,label,align?,render?,width?}], rows, keyField?, caption?, dense?` | Responsive wrapper `.table-wrap`. | | |
| 56 | −| `Badge` / `MethodBadge` / `StatusBadge` | `tone='neutral'|'success'|'info'|'warn'|'danger'` / `method` / `code` | | | |
| 86 | +| `CodeBlock` (alias `Code`) | `code, language='text', title?, copy=true, wrapToggle=true, wrap?, maxHeight?, lineNumbers=false, actions?, footer?` | Prism (lazy chunk, theme via `--code-*` vars, bash/R/toml/http grammars added). Header: title, extra `actions`, wrap toggle (persisted `hfmd.codewrap`), copy. `footer` = caption under the code. | | |
| 87 | +| `CopyButton` / `useCopy()` | `text (string \| () => string), label='Copy', size='sm'\|'md', iconOnly, variant='ghost'\|'secondary'` | The only clipboard helper. | | |
| 88 | +| `CodeTabs` | `snippets, title?, maxHeight?, footer?, lineNumbers?` | `snippets` = `{ curl, python, javascript, r }` (any subset) **or** `[{ id, label, language, code }]`. Uses the global persisted language (`hfmd.lang`). | | |
| 89 | +| `Button` / `IconButton` | `variant='primary'\|'secondary'\|'ghost'\|'danger', size='sm'\|'md'\|'lg', to?, href?, block, loading, disabled` / `label (required), to?, href?` | Renders `<Link>` / `<a>` when `to` / `href` is set. | | |
| 90 | +| `Tabs` | `items:[{id,label,content?,icon?,badge?,disabled?}], value?, onChange?, defaultValue?, size='md'\|'sm', right?, ariaLabel, panel=true, children?(activeId)` | Accessible tablist (roving focus, arrows/Home/End, skips disabled). `right` = slot at the end of the bar. | | |
| 91 | +| `Callout` | `type='info'\|'tip'\|'success'\|'warning'\|'danger', title?, action?, compact` | `kind` accepted as alias (`warn` → `warning`) — `PgCallout.jsx` re-exports it for the dashboard/auth pages. `role=alert` for warning/danger. | | |
| 92 | +| `Card` | `as='div', to?, size='md'\|'sm'` | Link card when `to` is set. | | |
| 93 | +| `Table` | `columns:[{key,label,align?,render?,width?,mono?}], rows, keyField?, caption?, dense, stickyFirst, stack, maxHeight?, emptyText` | Sticky header inside `.table-wrap`; `stack` turns rows into label/value pairs under 640 px (cells carry `data-label`). | | |
| 94 | +| `Field` / `Input` / `Select` / `Textarea` | `Field: label, hint, error, required, id?, inline, labelExtra` — wires `id`, `aria-describedby`, `aria-invalid` on its single child. `Select` accepts `options` (strings or `{value,label}`). Controls accept `invalid`, `Input` accepts `mono`. | | |
| 95 | +| `Skeleton` | `width, height, lines?, block` | Shimmer placeholder. | | |
| 96 | +| `EmptyState` | `icon?, title, children, actions?` | Dashed box, centred. | | |
| 97 | +| `ErrorState` | `status, code?, message?, title?, retryUntil? (epoch s), authenticated, docs?, onRetry?, actions?` | Network (status 0) / 4xx / **429 with live countdown + free-account CTA** / 5xx. Also exports `useCountdown(epochSec)`, `fmtDuration(s)`. | | |
| 98 | +| `Stat` | `label, value, hint?, delta?, loading` | Number tile (`delta` coloured with `--up`/`--down`). | | |
| 99 | +| `Badge` / `MethodBadge` / `StatusBadge` / `TierBadge` | `tone='neutral'\|'success'\|'info'\|'warn'\|'danger', dot` / `method` / `code` / `tier` | | | |
| 100 | +| `BrandLogo` | `name='claude'\|'cursor'\|'codex'\|'mcp'\|'skills'\|'python'\|'r'\|'javascript', size=40` | One source for the homepage and Integrations logos. | | |
| 57 | 101 | | `LangSelector` | `value?, onChange?, size` | Radio group over `LANGS` (`curl, python, javascript, r`); `useLang()` → `[lang, setLang]`; wrap app in `LangProvider`. | |
| 58 | −| `Markdown` / `InlineMd` | `text` or children, `headingOffset?` | Safe Markdown → React (no innerHTML): headings, lists, fenced code, tables, blockquotes, inline code/bold/italic/links (unsafe schemes dropped; site links become router `Link`s). | | |
| 59 | −| `SearchDialog` | `open, onClose` | ⌘K/Ctrl+K and `/` are bound in `Layout`; index built lazily from spec + guides. | | |
| 60 | −| `Icons` | — | Stroke icons (`SunIcon`, `CopyIcon`, `PlayIcon`, …), 18px, currentColor. | | |
| 102 | +| `Markdown` / `InlineMd` | `text` or children, `headingOffset?` | Safe Markdown → React (no innerHTML). | | |
| 103 | +| `SearchDialog` | `open, onClose, initialQuery?` | ⌘K/Ctrl+K and `/` are bound in `Layout`; any page can open it with a query: `window.dispatchEvent(new CustomEvent('hfmd:search', { detail: 'rate limits' }))`. | | |
| 104 | +| `useMediaQuery(query)` | → boolean | Used by the docs layout to move the code panel inline under 1200 px. | | |
| 105 | +| `Icons` | — | Stroke icons (`SunIcon`, `CopyIcon`, `PlayIcon`, `WrapIcon`, `CheckCircleIcon`, `InboxIcon`, `WifiOffIcon`, …), 18px, currentColor. | | |
| 61 | 106 | |
| 62 | −CSS classes reusable anywhere: `.btn .btn-primary .btn-ghost .btn-sm .btn-lg`, `.card`, `.grid .grid-2/3/4`, `.eyebrow`, | |
| 63 | −`.lead`, `.muted`, `.small`, `.mono`, `.num`, `.table-wrap`, `.sr-only`, `.skeleton` (home.css). | |
| 107 | +Compatibility aliases kept for pages owned by other agents: `components/PgCallout.jsx` (→ `Callout`, `kind` prop) and | |
| 108 | +`components/PgCopyButton.jsx` (→ `CopyButton`, `{ text, label, small }`). `PgTabs`, `PgCodeBlock`, `IntCopyBlock`, | |
| 109 | +`Code.jsx`, `src/styles.css` and `src/legacy-spec.js` were removed. | |
| 64 | 110 | |
| 65 | 111 | ## Docs engine |
| 66 | 112 | |
@@ -73,38 +119,46 @@ CSS classes reusable anywhere: `.btn .btn-primary .btn-ghost .btn-sm .btn-lg`, ` | ||
| 73 | 119 | (resolved, with type/default/enum/constraints), responses }`; `groupByTag` orders by `spec.tags`. |
| 74 | 120 | FastAPI's `^(a|b)$` patterns are surfaced as enums. |
| 75 | 121 | 3. `enrich.js › exampleParams(op)` derives realistic values: spec `example` → `PATH_OVERRIDES["METHOD /path"]` → |
| 76 | − `NAME_DEFAULTS[paramName]` (AAPL, ES, ESZ25, CL, 2024-06-03…) → schema default/enum. `buildRequest(op, values)` | |
| 77 | − → `{ url, rel, query, format }`; `playgroundLink(op, values)`. | |
| 78 | −4. `snippets.js › snippetsFor({ method, url, format, auth, stream })` → `{ curl, python, javascript, r }` | |
| 79 | − (requests+pandas, fetch, httr2; Parquet/CSV parsing lines vary with `format`; `$HFMD_API_KEY` env var, never a key). | |
| 80 | −5. Response example: spec example (`responseExample`) → `content/examples/<operationId>.json` (captured at build by | |
| 81 | − `npm run examples`) → live fetch for public GET operations (cached in memory). Errors: `errorResponses()` parses the | |
| 82 | − `\`CODE\`` markers that `api/openapi.py` writes into 4xx/5xx descriptions; every code links to `/docs/errors#<code>`. | |
| 83 | −6. Routes: `/docs/reference` (index), `/docs/reference/tag/<tag>`, `/docs/reference/<operationId>`, `/docs/errors`. | |
| 122 | + `NAME_DEFAULTS[paramName]` → schema default/enum. `buildRequest(op, values)` → `{ url, rel, query, format }`; | |
| 123 | + `playgroundLink(op, values)`. | |
| 124 | +4. `snippets.js › snippetsFor({ method, url, format, auth, stream })` → `{ curl, python, javascript, r }`. | |
| 125 | +5. Response example: spec example → `content/examples/<operationId>.json` (captured by `npm run examples`) → | |
| 126 | + **nothing automatic**. Public GET pages show a *Run live* button in the response header (one request of the | |
| 127 | + visitor's quota; 429 → "rate limited — not down" + countdown). | |
| 128 | +6. Layout: `DocPage({ intro, children, after, panel })`. Above 1200 px the panel (On this page → request → response) | |
| 129 | + is a sticky aside; below, `useMediaQuery` renders it **inside the article right after `intro`** (before | |
| 130 | + Parameters) — DOM order = visual order, no CSS reordering. Content column ≤ 720 px + 420 px panel. | |
| 131 | +7. Headings: the `#` anchor is an empty `<a class="anchor">` drawn with `::before`, so `textContent` stays clean. | |
| 132 | +8. Routes: `/docs/reference` (index), `/docs/reference/tag/<tag>`, `/docs/reference/<operationId>`, `/docs/errors`. | |
| 84 | 133 | |
| 85 | 134 | **Guides** are MDX in `content/guides/`. The `virtual:guides-index` Vite plugin (in `vite.config.js`) extracts `##`/`###` |
| 86 | 135 | headings of every guide at build time → search index + "On this page" outline without loading the guide chunks. |
| 87 | −Heading ids use the same `slugify` in the plugin and in `mdx-components.jsx`. | |
| 88 | 136 | |
| 89 | 137 | ### Add a guide |
| 90 | 138 | |
| 91 | −1. Create `content/guides/<file>.mdx`. Optional exports: | |
| 92 | − ```js | |
| 93 | − export const meta = { title, description } | |
| 94 | − export const snippets = [{ title, curl, python, javascript, r }] // → right-hand code panel | |
| 95 | − ``` | |
| 96 | − Components available without import: `Callout`, `CodeTabs`, `Tabs`, `Table`, `Badge`, `MethodBadge`, `Code`, | |
| 97 | − `Endpoint method path id?` (chip linking to the reference), `TryIt ep params`, `Url path`, `Figure caption`, `Link`. | |
| 98 | − MDX caveats: no bare `<` or `{` in prose (use code spans), SVG attributes in camelCase. | |
| 99 | −2. Register it in `src/docs/guides.js › GUIDES` (`slug`, `title`, `section`, `file`, `summary`). Sections: | |
| 100 | − `start · guides · recipes · fundamentals · more`. Nav, search, prev/next and the sitemap pick it up automatically. | |
| 139 | +1. Create `content/guides/<file>.mdx`. Optional exports: `meta = { title, description }`, | |
| 140 | + `snippets = [{ title, curl, python, javascript, r }]` (→ right-hand code panel). Components available without | |
| 141 | + import: `Callout`, `CodeTabs`, `Tabs`, `Table`, `Badge`, `MethodBadge`, `Code`, `Endpoint method path id?`, | |
| 142 | + `TryIt ep params`, `Url path`, `Figure caption`, `Link`. MDX caveats: no bare `<` or `{` in prose, SVG attributes | |
| 143 | + in camelCase. | |
| 144 | +2. Register it in `src/docs/guides.js › GUIDES` (`slug`, `title`, `section`, `file`, `summary`). Nav, search, | |
| 145 | + prev/next, sitemap **and the prerendered shell** pick it up automatically (`scripts/prerender.mjs` parses `GUIDES`). | |
| 101 | 146 | |
| 102 | 147 | ### Errors page |
| 103 | 148 | |
| 104 | 149 | `/docs/errors` lists every code of `components.schemas.Error.properties.code.enum` (plus curated meaning/fix in |
| 105 | 150 | `ErrorsPage.jsx › ERROR_INFO`) with anchors `#<code_lower>` — the API's `error.docs` URLs point there. |
| 106 | 151 | |
| 107 | −## Playground deep-link contract (`/playground?ep=…`) | |
| 152 | +## Playground | |
| 153 | + | |
| 154 | +Layout: **endpoint picker** (`EndpointPicker.jsx`, a command-palette combobox: search by title/path/group, grouped | |
| 155 | +listbox, ↑ ↓ Enter Esc) + preset chips → **request card** (parameters grid built from `catalog.js`, URL bar, Run — | |
| 156 | +sticky at the bottom on mobile) → **results** full width (tabs JSON / Table / Chart / Code / Download) → rate-limit | |
| 157 | +panel. Empty state = the captured production example of the endpoint (`content/examples/<operationId>.json`) with | |
| 158 | +"Edit a parameter and run". `catalog.js › normalizeValues()` recomputes dependent enums (e.g. `adjustment` after | |
| 159 | +`asset` arrives from a deep link) so Run is never blocked by "Not an allowed value". | |
| 160 | + | |
| 161 | +### Deep-link contract (`/playground?ep=…`) | |
| 108 | 162 | |
| 109 | 163 | Every reference page's **Try it** button and the MDX `<TryIt>` component produce: |
| 110 | 164 | |
@@ -112,44 +166,41 @@ Every reference page's **Try it** button and the MDX `<TryIt>` component produce | ||
| 112 | 166 | /playground?ep=<operationId>&<paramName>=<value>&<paramName>=<value>… |
| 113 | 167 | ``` |
| 114 | 168 | |
| 115 | −- `ep` — the OpenAPI `operationId` (e.g. `bars_v1_bars__asset___ticker__get`). The playground should resolve it via | |
| 116 | − `listOperations(spec)` from `src/docs/spec.js` and select that endpoint. | |
| 117 | −- Every other query key is a **parameter name of that operation** (path or query), URL-encoded, already validated | |
| 118 | − against the spec's enums/patterns by construction. Unknown keys should be ignored. | |
| 119 | −- `format` may be present (`json|csv|parquet`); `api_key` is never included. | |
| 120 | −- Helpers to reuse: `exampleParams(op)`, `buildRequest(op, values)`, `snippetsFor(...)`, `rateHeaders(res)` (`api.js`). | |
| 169 | +- `ep` — the OpenAPI `operationId` (e.g. `bars_v1_bars__asset___ticker__get`) or a catalog id (`stock-bars`). The | |
| 170 | + playground resolves it via `catalog.js › findEntry` (fixed params like `asset=futures` pick the matching entry), then | |
| 171 | + `/openapi.json` for unknown ids (generic form). | |
| 172 | +- Every other query key is a **parameter name of that operation**, URL-encoded. Unknown keys are ignored. `format` may be | |
| 173 | + present (`json|csv|parquet`); `api_key` is never included. | |
| 174 | +- A deep link with `ep` **auto-runs once** when the form validates (one request). `/playground` alone never calls the API. | |
| 121 | 175 | |
| 122 | 176 | ## Prerender (LCP) |
| 123 | 177 | |
| 124 | 178 | `npm run build` runs `vite build` (with `build.manifest`) then `scripts/prerender.mjs`, which writes static shells for |
| 125 | −`/`, `/limits`, `/docs`, `/docs/quickstart`, `/status` — as both `dist/<route>/index.html` and `dist/<route>.html` so | |
| 126 | −every static server finds them (`/` in place) — plus `dist/sitemap.xml`: | |
| 127 | − | |
| 128 | −- route-specific `<title>`, description, OpenGraph, canonical; | |
| 129 | −- `<link rel="modulepreload">` for the route's whole chunk graph and `<link rel="stylesheet">` for its CSS — the | |
| 130 | − lazy-route waterfall disappears, so the first paint of the real page is one round-trip after HTML; | |
| 131 | −- above-the-fold markup (header, h1, lead) inside `#root`, styled by the already-linked CSS, so LCP happens before | |
| 132 | − React mounts. `createRoot().render()` then swaps it for the live tree. | |
| 133 | − | |
| 134 | −Trade-off: this is *not* SSR — dynamic content (counters, spec-driven reference) is not in the HTML. Full SSR would | |
| 135 | −require a second SSR bundle and a Node render per route around lazy routes, MDX and browser-only libraries | |
| 136 | −(lightweight-charts), for pages whose content is mostly fetched live anyway. The shell approach costs ~60 lines and no | |
| 137 | −runtime dependency. **Backend note**: the FastAPI SPA fallback serves `dist/index.html` for directory paths; to serve the | |
| 138 | −shells it should try, in order, `candidate`, `candidate.with_suffix(".html")` and `candidate / "index.html"` before | |
| 139 | −falling back to `index.html`. | |
| 179 | +`/`, `/limits`, `/status`, `/playground`, `/integrations`, `/terms`, `/privacy`, `/data-license`, `/docs`, | |
| 180 | +`/docs/reference`, `/docs/errors` and **every guide in `GUIDES`** — as both `dist/<route>/index.html` and | |
| 181 | +`dist/<route>.html` — plus `dist/sitemap.xml`. Each shell has route-specific `<title>`, description, OpenGraph, | |
| 182 | +canonical, `<link rel="modulepreload">` for the route's chunk graph (guide chunk included), its CSS, and the h1 + lead | |
| 183 | +inside `#root`. Trade-off: not SSR — dynamic content is not in the HTML (see git history for the reasoning). | |
| 184 | +**Backend note**: the SPA fallback should try `candidate`, `candidate.html` and `candidate/index.html` before `index.html`, | |
| 185 | +and return 404 for unknown paths (the client renders `NotFound`). | |
| 140 | 186 | |
| 141 | 187 | ## Scripts |
| 142 | 188 | |
| 143 | 189 | | Script | Purpose | |
| 144 | 190 | | --- | --- | |
| 145 | −| `scripts/fetch-examples.mjs` | For each public GET operation without a spec example, call production with realistic params and store `content/examples/<operationId>.json` (`{ url, params, status, headers, captured_at, body }`, `data` trimmed to 5 rows). Refreshes `content/openapi.snapshot.json` when the live spec has the `Error` schema. Flags: `--base`, `--only a,b`, `--force`. | | |
| 146 | −| `scripts/make-aapl-pe-sample.mjs` | Builds `content/samples/aapl-pe.json` (weekly AAPL split-adjusted close ÷ 10-K diluted EPS, labelled as such) — homepage chart fallback until `/v1/fundamentals/AAPL/ratios/daily` is live. | | |
| 191 | +| `scripts/fetch-examples.mjs` | For each public GET operation without a spec example, call production with realistic params and store `content/examples/<operationId>.json`. Flags: `--base`, `--only a,b`, `--force`. | | |
| 192 | +| `scripts/make-brand-assets.mjs` | Renders `public/og.png` (1200×630), `apple-touch-icon.png` (180) and `icon-{32,192,512}.png` from inline HTML with Playwright. | | |
| 193 | +| `scripts/make-aapl-pe-sample.mjs` | Builds `content/samples/aapl-pe.json` (weekly AAPL split-adjusted close ÷ 10-K diluted EPS) — homepage fundamentals chart. | | |
| 147 | 194 | | `scripts/bench-formats.mjs` + `bench-parquet.py` | Real JSON/CSV/Parquet size measurements used in the Data formats guide. | |
| 148 | 195 | | `scripts/prerender.mjs` | See above. | |
| 149 | 196 | |
| 150 | 197 | ## E2E |
| 151 | 198 | |
| 152 | 199 | `playwright.config.js` starts `vite preview` on :4173 (Chromium desktop + Pixel 7 for `mobile.spec.js`). Without the |
| 153 | −API, docs render from the bundled snapshot and the homepage/status pages exercise their fallbacks — which is exactly | |
| 154 | −what the tests assert (docs navigation, ⌘K search with keyboard, Try-it link format, limits content and CTAs, theme | |
| 155 | −toggle persistence, prerendered shells, mobile drawer/docs nav). Browsers: `npx playwright install chromium`. | |
| 200 | +API, docs render from the bundled snapshot and the homepage/status pages exercise their fallbacks. Specs cover: home | |
| 201 | +(single `/v1/status` call, captured hero, footer, no overflow), limits, theme persistence + light-mode code blocks, | |
| 202 | +status (down vs **429 = rate limited**, last dates), 404 (search seeds the ⌘K dialog), legal pages, prerendered | |
| 203 | +shells for every guide, tap targets; docs (outline order, Run live explicit, wrap toggle, clean heading text, search, | |
| 204 | +anchors); playground (endpoint picker, captured empty state, deep link auto-run, dependent enums, 429 countdown, CSV, | |
| 205 | +WebSocket gating); mobile (drawer, inline docs panel order, playground form + 44 px Run, no overflow on every public | |
| 206 | +page). Browsers: `npx playwright install chromium`. | |
added
hfmarketdata/web/content/examples/home-aapl-daily.json
+263 −0
@@ -0,0 +1,263 @@ | ||
| 1 | +{ | |
| 2 | + "note": "Real response captured from production on 2026-09-06 for the homepage chart (GET /v1/bars/stock/AAPL?timeframe=1day&start=2025-09-02&end=2026-09-03, adj_splitdiv), OHLC rounded to 2 decimals, volume dropped. Columns: datetime, open, high, low, close. Refresh with npm run examples.", | |
| 3 | + "url": "https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day&start=2025-09-02&end=2026-09-03", | |
| 4 | + "captured_at": "2026-09-06T14:20:00Z", | |
| 5 | + "ticker": "AAPL", | |
| 6 | + "columns": ["datetime", "open", "high", "low", "close"], | |
| 7 | + "rows": [ | |
| 8 | + ["2025-09-02",228.41,230.0,226.13,228.87], | |
| 9 | + ["2025-09-03",236.34,237.97,233.5,237.59], | |
| 10 | + ["2025-09-04",237.57,239.02,235.87,238.9], | |
| 11 | + ["2025-09-05",239.11,240.43,237.61,238.81], | |
| 12 | + ["2025-09-08",238.42,239.27,235.47,237.0], | |
| 13 | + ["2025-09-09",236.13,237.9,232.5,233.49], | |
| 14 | + ["2025-09-10",231.33,231.56,225.12,225.96], | |
| 15 | + ["2025-09-11",226.04,229.6,225.82,229.18], | |
| 16 | + ["2025-09-12",228.38,233.65,228.18,233.21], | |
| 17 | + ["2025-09-15",236.13,237.31,234.16,235.83], | |
| 18 | + ["2025-09-16",236.3,240.33,235.45,237.27], | |
| 19 | + ["2025-09-17",238.09,239.22,236.86,238.11], | |
| 20 | + ["2025-09-18",239.09,240.31,235.78,237.0], | |
| 21 | + ["2025-09-19",240.34,245.39,239.33,244.6], | |
| 22 | + ["2025-09-22",247.39,255.7,247.21,255.14], | |
| 23 | + ["2025-09-23",254.93,256.39,252.65,253.49], | |
| 24 | + ["2025-09-24",254.28,254.8,250.12,251.38], | |
| 25 | + ["2025-09-25",252.27,256.22,250.79,255.92], | |
| 26 | + ["2025-09-26",253.16,256.65,252.85,254.52], | |
| 27 | + ["2025-09-29",253.62,254.06,252.08,253.49], | |
| 28 | + ["2025-09-30",253.92,254.98,252.18,253.69], | |
| 29 | + ["2025-10-01",254.1,257.84,253.99,254.51], | |
| 30 | + ["2025-10-02",255.63,257.23,253.21,256.18], | |
| 31 | + ["2025-10-03",253.73,258.29,253.02,257.07], | |
| 32 | + ["2025-10-06",257.04,258.12,254.11,255.75], | |
| 33 | + ["2025-10-07",255.86,256.45,254.49,255.54], | |
| 34 | + ["2025-10-08",255.58,257.57,255.17,257.11], | |
| 35 | + ["2025-10-09",256.86,257.05,252.21,253.1], | |
| 36 | + ["2025-10-10",254.0,255.44,243.1,244.37], | |
| 37 | + ["2025-10-13",248.46,248.77,244.66,246.75], | |
| 38 | + ["2025-10-14",245.69,247.93,243.8,246.86], | |
| 39 | + ["2025-10-15",248.57,250.89,246.56,248.42], | |
| 40 | + ["2025-10-16",247.34,248.12,244.23,246.54], | |
| 41 | + ["2025-10-17",247.11,252.45,246.36,251.36], | |
| 42 | + ["2025-10-20",254.94,263.4,254.69,261.27], | |
| 43 | + ["2025-10-21",260.92,264.31,260.87,261.8], | |
| 44 | + ["2025-10-22",261.68,261.88,254.49,257.5], | |
| 45 | + ["2025-10-23",258.98,259.66,257.06,258.62], | |
| 46 | + ["2025-10-24",260.23,263.16,258.23,261.85], | |
| 47 | + ["2025-10-27",263.91,268.13,263.68,267.82], | |
| 48 | + ["2025-10-28",268.0,268.9,267.16,268.01], | |
| 49 | + ["2025-10-29",268.28,270.41,266.13,268.71], | |
| 50 | + ["2025-10-30",270.99,273.13,267.49,270.4], | |
| 51 | + ["2025-10-31",275.97,276.3,268.17,269.37], | |
| 52 | + ["2025-11-03",269.42,269.85,265.27,268.06], | |
| 53 | + ["2025-11-04",267.34,270.49,266.63,269.05], | |
| 54 | + ["2025-11-05",267.62,270.7,265.95,269.15], | |
| 55 | + ["2025-11-06",266.9,272.39,266.9,268.78], | |
| 56 | + ["2025-11-07",268.8,271.29,265.79,267.48], | |
| 57 | + ["2025-11-10",268.23,272.99,266.73,268.7], | |
| 58 | + ["2025-11-11",269.08,275.16,269.07,274.5], | |
| 59 | + ["2025-11-12",274.25,274.98,270.96,272.73], | |
| 60 | + ["2025-11-13",273.37,275.95,271.35,272.21], | |
| 61 | + ["2025-11-14",270.31,275.21,268.87,271.67], | |
| 62 | + ["2025-11-17",268.09,269.76,265.01,266.73], | |
| 63 | + ["2025-11-18",269.26,269.98,264.6,266.71], | |
| 64 | + ["2025-11-19",264.8,271.47,264.78,267.83], | |
| 65 | + ["2025-11-20",270.09,274.68,265.2,265.53], | |
| 66 | + ["2025-11-21",265.23,272.59,264.95,270.75], | |
| 67 | + ["2025-11-24",270.16,276.25,270.16,275.17], | |
| 68 | + ["2025-11-25",274.52,279.62,274.5,276.22], | |
| 69 | + ["2025-11-26",276.21,278.77,275.88,276.8], | |
| 70 | + ["2025-11-28",276.51,278.24,275.24,278.09], | |
| 71 | + ["2025-12-01",277.26,282.65,275.39,282.33], | |
| 72 | + ["2025-12-02",282.23,286.62,281.86,285.41], | |
| 73 | + ["2025-12-03",285.42,287.84,282.53,283.38], | |
| 74 | + ["2025-12-04",283.32,283.96,277.83,279.94], | |
| 75 | + ["2025-12-05",279.78,280.38,277.3,278.02], | |
| 76 | + ["2025-12-08",277.38,278.91,275.4,277.14], | |
| 77 | + ["2025-12-09",277.4,279.27,276.17,276.43], | |
| 78 | + ["2025-12-10",277.0,278.99,275.69,278.02], | |
| 79 | + ["2025-12-11",278.34,278.83,273.07,277.28], | |
| 80 | + ["2025-12-12",277.15,278.46,276.07,277.52], | |
| 81 | + ["2025-12-15",279.39,279.39,272.1,273.37], | |
| 82 | + ["2025-12-16",272.08,274.75,271.05,273.86], | |
| 83 | + ["2025-12-17",274.26,275.41,270.9,271.1], | |
| 84 | + ["2025-12-18",272.86,272.89,266.23,271.45], | |
| 85 | + ["2025-12-19",271.41,273.85,269.17,272.93], | |
| 86 | + ["2025-12-22",272.12,273.14,269.77,270.23], | |
| 87 | + ["2025-12-23",270.1,271.76,268.83,271.62], | |
| 88 | + ["2025-12-24",271.6,274.68,271.46,273.07], | |
| 89 | + ["2025-12-26",273.42,274.62,272.12,272.66], | |
| 90 | + ["2025-12-29",271.95,273.62,271.61,273.02], | |
| 91 | + ["2025-12-30",272.07,273.34,271.54,272.34], | |
| 92 | + ["2025-12-31",272.32,272.94,271.01,271.12], | |
| 93 | + ["2026-01-02",271.52,277.09,268.27,270.27], | |
| 94 | + ["2026-01-05",269.91,270.77,265.42,266.53], | |
| 95 | + ["2026-01-06",266.28,266.82,261.41,261.65], | |
| 96 | + ["2026-01-07",262.49,262.96,259.1,259.62], | |
| 97 | + ["2026-01-08",256.32,258.59,255.01,258.34], | |
| 98 | + ["2026-01-09",258.37,259.5,255.52,258.67], | |
| 99 | + ["2026-01-12",258.46,260.59,256.1,259.54], | |
| 100 | + ["2026-01-13",258.02,261.1,257.69,260.34], | |
| 101 | + ["2026-01-14",258.79,261.11,256.01,259.25], | |
| 102 | + ["2026-01-15",259.94,260.33,256.35,257.51], | |
| 103 | + ["2026-01-16",257.2,258.2,254.24,254.84], | |
| 104 | + ["2026-01-20",252.04,254.1,242.76,246.03], | |
| 105 | + ["2026-01-21",248.02,250.88,244.51,246.98], | |
| 106 | + ["2026-01-22",248.52,250.32,247.48,247.68], | |
| 107 | + ["2026-01-23",246.65,248.73,244.02,247.37], | |
| 108 | + ["2026-01-26",250.8,255.86,249.12,254.72], | |
| 109 | + ["2026-01-27",258.47,261.24,257.51,257.57], | |
| 110 | + ["2026-01-28",256.95,258.15,253.82,255.74], | |
| 111 | + ["2026-01-29",257.3,258.95,253.72,257.58], | |
| 112 | + ["2026-01-30",254.47,261.19,251.5,258.78], | |
| 113 | + ["2026-02-02",259.32,269.76,258.5,269.28], | |
| 114 | + ["2026-02-03",268.47,271.14,266.88,268.75], | |
| 115 | + ["2026-02-04",271.55,278.19,271.55,275.74], | |
| 116 | + ["2026-02-05",277.38,278.74,272.49,275.16], | |
| 117 | + ["2026-02-06",276.37,280.14,276.17,277.37], | |
| 118 | + ["2026-02-09",277.41,277.7,271.22,274.13], | |
| 119 | + ["2026-02-10",274.4,274.88,272.45,273.19], | |
| 120 | + ["2026-02-11",274.21,279.68,273.96,275.01], | |
| 121 | + ["2026-02-12",275.1,275.23,259.72,261.26], | |
| 122 | + ["2026-02-13",261.54,261.76,254.99,255.32], | |
| 123 | + ["2026-02-17",257.59,265.82,255.08,263.41], | |
| 124 | + ["2026-02-18",263.13,266.34,261.98,263.88], | |
| 125 | + ["2026-02-19",262.13,264.01,259.59,260.12], | |
| 126 | + ["2026-02-20",258.51,264.28,257.7,264.11], | |
| 127 | + ["2026-02-23",263.02,268.95,262.91,265.71], | |
| 128 | + ["2026-02-24",267.38,274.4,267.23,271.66], | |
| 129 | + ["2026-02-25",271.3,274.45,270.57,273.74], | |
| 130 | + ["2026-02-26",274.46,275.62,270.31,272.46], | |
| 131 | + ["2026-02-27",272.32,272.32,262.42,263.71], | |
| 132 | + ["2026-03-02",261.94,266.06,259.74,264.25], | |
| 133 | + ["2026-03-03",263.01,265.09,259.67,263.28], | |
| 134 | + ["2026-03-04",264.18,265.68,260.96,262.05], | |
| 135 | + ["2026-03-05",260.33,261.09,256.79,259.83], | |
| 136 | + ["2026-03-06",258.17,258.31,253.92,257.0], | |
| 137 | + ["2026-03-09",255.23,260.68,253.23,259.42], | |
| 138 | + ["2026-03-10",257.19,262.01,256.49,260.37], | |
| 139 | + ["2026-03-11",260.62,261.66,259.09,260.35], | |
| 140 | + ["2026-03-12",258.2,258.49,253.73,255.3], | |
| 141 | + ["2026-03-13",255.02,255.87,249.11,249.67], | |
| 142 | + ["2026-03-16",251.66,253.43,249.43,252.37], | |
| 143 | + ["2026-03-17",252.5,254.68,251.73,253.78], | |
| 144 | + ["2026-03-18",252.18,254.49,248.56,249.49], | |
| 145 | + ["2026-03-19",248.96,251.38,246.86,248.52], | |
| 146 | + ["2026-03-20",247.53,248.76,245.56,247.55], | |
| 147 | + ["2026-03-23",253.52,254.15,249.83,251.04], | |
| 148 | + ["2026-03-24",249.9,254.37,249.11,251.19], | |
| 149 | + ["2026-03-25",253.64,254.55,251.15,252.17], | |
| 150 | + ["2026-03-26",251.67,256.54,250.32,252.44], | |
| 151 | + ["2026-03-27",253.45,255.04,247.63,248.36], | |
| 152 | + ["2026-03-30",249.62,250.42,245.6,245.87], | |
| 153 | + ["2026-03-31",247.47,255.02,246.66,253.34], | |
| 154 | + ["2026-04-01",253.63,255.72,252.88,255.17], | |
| 155 | + ["2026-04-02",253.75,255.67,250.2,255.46], | |
| 156 | + ["2026-04-06",256.05,261.69,256.0,258.4], | |
| 157 | + ["2026-04-07",255.7,255.74,245.26,253.05], | |
| 158 | + ["2026-04-08",257.99,259.29,256.07,258.44], | |
| 159 | + ["2026-04-09",258.54,260.65,255.61,260.03], | |
| 160 | + ["2026-04-10",259.52,261.72,258.56,260.02], | |
| 161 | + ["2026-04-13",259.27,259.72,256.2,258.74], | |
| 162 | + ["2026-04-14",258.78,261.46,256.73,258.37], | |
| 163 | + ["2026-04-15",257.7,266.09,257.35,265.96], | |
| 164 | + ["2026-04-16",266.32,266.68,260.8,262.93], | |
| 165 | + ["2026-04-17",266.48,271.81,266.24,269.75], | |
| 166 | + ["2026-04-20",269.85,273.79,269.81,272.56], | |
| 167 | + ["2026-04-21",271.02,272.31,264.93,265.7], | |
| 168 | + ["2026-04-22",267.34,273.25,266.39,272.68], | |
| 169 | + ["2026-04-23",274.56,275.28,271.17,272.94], | |
| 170 | + ["2026-04-24",272.27,272.57,269.17,270.58], | |
| 171 | + ["2026-04-27",265.62,267.88,264.6,267.13], | |
| 172 | + ["2026-04-28",271.85,272.74,268.18,270.23], | |
| 173 | + ["2026-04-29",267.07,270.56,266.56,269.69], | |
| 174 | + ["2026-04-30",270.02,275.51,267.66,270.87], | |
| 175 | + ["2026-05-01",278.36,286.71,277.87,279.64], | |
| 176 | + ["2026-05-04",279.16,280.13,274.37,276.34], | |
| 177 | + ["2026-05-05",276.43,284.06,276.01,283.67], | |
| 178 | + ["2026-05-06",281.41,287.52,280.57,287.0], | |
| 179 | + ["2026-05-07",288.75,291.61,285.27,286.93], | |
| 180 | + ["2026-05-08",289.49,294.23,289.48,292.8], | |
| 181 | + ["2026-05-11",291.73,293.63,289.98,292.43], | |
| 182 | + ["2026-05-12",292.31,295.02,292.31,294.55], | |
| 183 | + ["2026-05-13",293.25,300.66,293.25,298.61], | |
| 184 | + ["2026-05-14",299.56,300.19,295.13,297.95], | |
| 185 | + ["2026-05-15",297.64,302.94,296.26,299.97], | |
| 186 | + ["2026-05-18",299.98,300.4,294.66,297.58], | |
| 187 | + ["2026-05-19",296.71,300.25,296.09,298.71], | |
| 188 | + ["2026-05-20",297.92,302.54,297.82,301.99], | |
| 189 | + ["2026-05-21",300.8,305.28,300.14,304.73], | |
| 190 | + ["2026-05-22",305.86,311.13,305.58,308.55], | |
| 191 | + ["2026-05-26",309.29,311.55,307.4,308.06], | |
| 192 | + ["2026-05-27",308.06,312.99,308.03,310.58], | |
| 193 | + ["2026-05-28",310.41,312.53,309.3,312.24], | |
| 194 | + ["2026-05-29",311.51,314.73,309.26,311.79], | |
| 195 | + ["2026-06-01",309.36,310.67,304.76,306.05], | |
| 196 | + ["2026-06-02",307.2,315.18,306.42,314.93], | |
| 197 | + ["2026-06-03",313.9,316.67,308.58,309.99], | |
| 198 | + ["2026-06-04",312.96,313.27,309.38,310.96], | |
| 199 | + ["2026-06-05",312.59,314.9,306.89,307.08], | |
| 200 | + ["2026-06-08",308.47,317.13,300.91,301.28], | |
| 201 | + ["2026-06-09",300.02,300.49,287.53,290.3], | |
| 202 | + ["2026-06-10",290.49,294.5,287.13,291.33], | |
| 203 | + ["2026-06-11",293.47,296.74,289.34,295.38], | |
| 204 | + ["2026-06-12",295.77,296.88,289.37,290.88], | |
| 205 | + ["2026-06-15",293.87,297.52,291.45,296.16], | |
| 206 | + ["2026-06-16",294.99,300.22,293.72,298.98], | |
| 207 | + ["2026-06-17",300.59,301.81,294.11,295.69], | |
| 208 | + ["2026-06-18",297.85,300.31,295.37,297.75], | |
| 209 | + ["2026-06-22",297.05,302.16,296.5,296.75], | |
| 210 | + ["2026-06-23",297.28,301.38,293.93,294.05], | |
| 211 | + ["2026-06-24",295.1,299.44,292.69,292.83], | |
| 212 | + ["2026-06-25",287.15,288.55,273.51,274.91], | |
| 213 | + ["2026-06-26",274.76,285.7,273.97,283.54], | |
| 214 | + ["2026-06-29",286.48,288.12,279.61,281.5], | |
| 215 | + ["2026-06-30",280.93,289.69,280.45,289.11], | |
| 216 | + ["2026-07-01",293.19,296.33,288.95,294.13], | |
| 217 | + ["2026-07-02",293.87,309.15,293.43,308.36], | |
| 218 | + ["2026-07-06",307.1,313.93,306.74,312.39], | |
| 219 | + ["2026-07-07",315.02,315.21,309.88,310.39], | |
| 220 | + ["2026-07-08",311.64,314.55,306.79,313.12], | |
| 221 | + ["2026-07-09",310.24,316.26,307.89,315.95], | |
| 222 | + ["2026-07-10",314.45,316.64,311.9,315.05], | |
| 223 | + ["2026-07-13",316.74,323.17,315.51,317.04], | |
| 224 | + ["2026-07-14",313.49,315.92,311.64,314.59], | |
| 225 | + ["2026-07-15",317.34,328.45,317.05,327.22], | |
| 226 | + ["2026-07-16",327.72,334.39,326.51,332.97], | |
| 227 | + ["2026-07-17",331.69,334.7,328.72,333.45], | |
| 228 | + ["2026-07-20",333.22,333.42,323.4,326.31], | |
| 229 | + ["2026-07-21",322.85,329.32,321.94,327.46], | |
| 230 | + ["2026-07-22",327.59,328.72,323.06,325.61], | |
| 231 | + ["2026-07-23",321.45,323.02,319.07,321.38], | |
| 232 | + ["2026-07-24",321.51,334.08,321.34,332.73], | |
| 233 | + ["2026-07-27",334.25,339.28,333.73,336.62], | |
| 234 | + ["2026-07-28",339.74,342.59,335.31,339.79], | |
| 235 | + ["2026-07-29",339.44,344.27,337.06,337.9], | |
| 236 | + ["2026-07-30",332.81,334.46,329.31,333.14], | |
| 237 | + ["2026-07-31",304.55,310.42,299.74,308.64], | |
| 238 | + ["2026-08-03",309.31,311.53,302.3,303.16], | |
| 239 | + ["2026-08-04",302.46,310.15,301.06,309.11], | |
| 240 | + ["2026-08-05",309.09,311.44,305.41,310.73], | |
| 241 | + ["2026-08-06",314.07,316.02,308.96,312.14], | |
| 242 | + ["2026-08-07",311.18,314.54,310.47,313.06], | |
| 243 | + ["2026-08-10",306.83,308.26,304.61,308.26], | |
| 244 | + ["2026-08-11",307.75,309.97,302.79,304.91], | |
| 245 | + ["2026-08-12",305.1,305.66,300.57,302.25], | |
| 246 | + ["2026-08-13",304.21,306,302.05,305.26], | |
| 247 | + ["2026-08-14",306,307.49,304.3,305.93], | |
| 248 | + ["2026-08-17",306.21,307.66,302.94,305.59], | |
| 249 | + ["2026-08-18",307.58,311.49,305.74,310.03], | |
| 250 | + ["2026-08-19",310.14,319.28,309.6,316.83], | |
| 251 | + ["2026-08-20",317.45,320.28,310.65,311.3], | |
| 252 | + ["2026-08-21",312.05,312.38,307.01,309.35], | |
| 253 | + ["2026-08-24",311.47,313.36,309.97,310.34], | |
| 254 | + ["2026-08-25",310.79,313.59,308.21,309.9], | |
| 255 | + ["2026-08-26",310.3,315.43,308.8,313.45], | |
| 256 | + ["2026-08-27",310.55,315.4,309.4,314.58], | |
| 257 | + ["2026-08-28",316.85,322.37,315.45,319.7], | |
| 258 | + ["2026-08-31",319.6,321.24,312.8,316.85], | |
| 259 | + ["2026-09-01",316.98,327.3,314.73,325.13], | |
| 260 | + ["2026-09-02",326.87,328.4,323.53,324.96], | |
| 261 | + ["2026-09-03",324.87,330.81,324.11,328.21] | |
| 262 | + ] | |
| 263 | +} | |
modified
hfmarketdata/web/content/samples/aapl-pe.json
+1 −1
@@ -1 +1 @@ | ||
| 1 | −{"ticker":"AAPL","metric":"pe","note":"Illustrative fallback used only when /v1/fundamentals/AAPL/ratios/daily is not live: split-adjusted weekly close (HF Market Data, adj_split) divided by the diluted EPS reported in Apple's latest Form 10-K at that date (10-K EPS stepped at each release; not a trailing-twelve-month series).","source":{"prices":"https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day&adjustment=adj_split&start=2015-01-01&limit=50000","eps":"Apple Inc. Form 10-K filings FY2014–FY2025 (diluted EPS, split-adjusted)"},"generated_at":"2026-09-04T23:38:04.919Z","points":[{"date":"2015-01-02","close":27.3325,"eps_ttm":1.61,"pe":16.98},{"date":"2015-01-09","close":28.0025,"eps_ttm":1.61,"pe":17.39},{"date":"2015-01-16","close":26.4975,"eps_ttm":1.61,"pe":16.46},{"date":"2015-01-23","close":28.245,"eps_ttm":1.61,"pe":17.54},{"date":"2015-01-30","close":29.29,"eps_ttm":1.61,"pe":18.19},{"date":"2015-02-06","close":29.7325,"eps_ttm":1.61,"pe":18.47},{"date":"2015-02-13","close":31.77,"eps_ttm":1.61,"pe":19.73},{"date":"2015-02-20","close":32.375,"eps_ttm":1.61,"pe":20.11},{"date":"2015-02-27","close":32.115,"eps_ttm":1.61,"pe":19.95},{"date":"2015-03-06","close":31.65,"eps_ttm":1.61,"pe":19.66},{"date":"2015-03-13","close":30.8975,"eps_ttm":1.61,"pe":19.19},{"date":"2015-03-20","close":31.475,"eps_ttm":1.61,"pe":19.55},{"date":"2015-03-27","close":30.8125,"eps_ttm":1.61,"pe":19.14},{"date":"2015-04-02","close":31.33,"eps_ttm":1.61,"pe":19.46},{"date":"2015-04-10","close":31.775,"eps_ttm":1.61,"pe":19.74},{"date":"2015-04-17","close":31.1875,"eps_ttm":1.61,"pe":19.37},{"date":"2015-04-24","close":32.57,"eps_ttm":1.61,"pe":20.23},{"date":"2015-05-01","close":32.2375,"eps_ttm":1.61,"pe":20.02},{"date":"2015-05-08","close":31.905,"eps_ttm":1.61,"pe":19.82},{"date":"2015-05-15","close":32.1925,"eps_ttm":1.61,"pe":20},{"date":"2015-05-22","close":33.135,"eps_ttm":1.61,"pe":20.58},{"date":"2015-05-29","close":32.57,"eps_ttm":1.61,"pe":20.23},{"date":"2015-06-05","close":32.1625,"eps_ttm":1.61,"pe":19.98},{"date":"2015-06-12","close":31.7925,"eps_ttm":1.61,"pe":19.75},{"date":"2015-06-19","close":31.65,"eps_ttm":1.61,"pe":19.66},{"date":"2015-06-26","close":31.6875,"eps_ttm":1.61,"pe":19.68},{"date":"2015-07-02","close":31.61,"eps_ttm":1.61,"pe":19.63},{"date":"2015-07-10","close":30.82,"eps_ttm":1.61,"pe":19.14},{"date":"2015-07-17","close":32.405,"eps_ttm":1.61,"pe":20.13},{"date":"2015-07-24","close":31.125,"eps_ttm":1.61,"pe":19.33},{"date":"2015-07-31","close":30.325,"eps_ttm":1.61,"pe":18.84},{"date":"2015-08-07","close":28.88,"eps_ttm":1.61,"pe":17.94},{"date":"2015-08-14","close":28.99,"eps_ttm":1.61,"pe":18.01},{"date":"2015-08-21","close":26.44,"eps_ttm":1.61,"pe":16.42},{"date":"2015-08-28","close":28.3225,"eps_ttm":1.61,"pe":17.59},{"date":"2015-09-04","close":27.3175,"eps_ttm":1.61,"pe":16.97},{"date":"2015-09-11","close":28.5525,"eps_ttm":1.61,"pe":17.73},{"date":"2015-09-18","close":28.3625,"eps_ttm":1.61,"pe":17.62},{"date":"2015-09-25","close":28.6775,"eps_ttm":1.61,"pe":17.81},{"date":"2015-10-02","close":27.595,"eps_ttm":1.61,"pe":17.14},{"date":"2015-10-09","close":28.03,"eps_ttm":1.61,"pe":17.41},{"date":"2015-10-16","close":27.76,"eps_ttm":1.61,"pe":17.24},{"date":"2015-10-23","close":29.77,"eps_ttm":1.61,"pe":18.49},{"date":"2015-10-30","close":29.875,"eps_ttm":2.31,"pe":12.93},{"date":"2015-11-06","close":30.265,"eps_ttm":2.31,"pe":13.1},{"date":"2015-11-13","close":28.085,"eps_ttm":2.31,"pe":12.16},{"date":"2015-11-20","close":29.825,"eps_ttm":2.31,"pe":12.91},{"date":"2015-11-27","close":29.4525,"eps_ttm":2.31,"pe":12.75},{"date":"2015-12-04","close":29.7575,"eps_ttm":2.31,"pe":12.88},{"date":"2015-12-11","close":28.295,"eps_ttm":2.31,"pe":12.25},{"date":"2015-12-18","close":26.5075,"eps_ttm":2.31,"pe":11.48},{"date":"2015-12-24","close":27.0075,"eps_ttm":2.31,"pe":11.69},{"date":"2015-12-31","close":26.315,"eps_ttm":2.31,"pe":11.39},{"date":"2016-01-08","close":24.24,"eps_ttm":2.31,"pe":10.49},{"date":"2016-01-15","close":24.2825,"eps_ttm":2.31,"pe":10.51},{"date":"2016-01-22","close":25.355,"eps_ttm":2.31,"pe":10.98},{"date":"2016-01-29","close":24.335,"eps_ttm":2.31,"pe":10.53},{"date":"2016-02-05","close":23.505,"eps_ttm":2.31,"pe":10.18},{"date":"2016-02-12","close":23.4975,"eps_ttm":2.31,"pe":10.17},{"date":"2016-02-19","close":24.01,"eps_ttm":2.31,"pe":10.39},{"date":"2016-02-26","close":24.2275,"eps_ttm":2.31,"pe":10.49},{"date":"2016-03-04","close":25.7525,"eps_ttm":2.31,"pe":11.15},{"date":"2016-03-11","close":25.565,"eps_ttm":2.31,"pe":11.07},{"date":"2016-03-18","close":26.48,"eps_ttm":2.31,"pe":11.46},{"date":"2016-03-24","close":26.4175,"eps_ttm":2.31,"pe":11.44},{"date":"2016-04-01","close":27.4975,"eps_ttm":2.31,"pe":11.9},{"date":"2016-04-08","close":27.165,"eps_ttm":2.31,"pe":11.76},{"date":"2016-04-15","close":27.4625,"eps_ttm":2.31,"pe":11.89},{"date":"2016-04-22","close":26.42,"eps_ttm":2.31,"pe":11.44},{"date":"2016-04-29","close":23.435,"eps_ttm":2.31,"pe":10.15},{"date":"2016-05-06","close":23.18,"eps_ttm":2.31,"pe":10.03},{"date":"2016-05-13","close":22.63,"eps_ttm":2.31,"pe":9.8},{"date":"2016-05-20","close":23.805,"eps_ttm":2.31,"pe":10.31},{"date":"2016-05-27","close":25.0875,"eps_ttm":2.31,"pe":10.86},{"date":"2016-06-03","close":24.48,"eps_ttm":2.31,"pe":10.6},{"date":"2016-06-10","close":24.7075,"eps_ttm":2.31,"pe":10.7},{"date":"2016-06-17","close":23.8325,"eps_ttm":2.31,"pe":10.32},{"date":"2016-06-24","close":23.35,"eps_ttm":2.31,"pe":10.11},{"date":"2016-07-01","close":23.9725,"eps_ttm":2.31,"pe":10.38},{"date":"2016-07-08","close":24.17,"eps_ttm":2.31,"pe":10.46},{"date":"2016-07-15","close":24.695,"eps_ttm":2.31,"pe":10.69},{"date":"2016-07-22","close":24.665,"eps_ttm":2.31,"pe":10.68},{"date":"2016-07-29","close":26.0525,"eps_ttm":2.31,"pe":11.28},{"date":"2016-08-05","close":26.87,"eps_ttm":2.31,"pe":11.63},{"date":"2016-08-12","close":27.045,"eps_ttm":2.31,"pe":11.71},{"date":"2016-08-19","close":27.34,"eps_ttm":2.31,"pe":11.84},{"date":"2016-08-26","close":26.735,"eps_ttm":2.31,"pe":11.57},{"date":"2016-09-02","close":26.9325,"eps_ttm":2.31,"pe":11.66},{"date":"2016-09-09","close":25.7825,"eps_ttm":2.31,"pe":11.16},{"date":"2016-09-16","close":28.73,"eps_ttm":2.31,"pe":12.44},{"date":"2016-09-23","close":28.1775,"eps_ttm":2.31,"pe":12.2},{"date":"2016-09-30","close":28.2625,"eps_ttm":2.31,"pe":12.23},{"date":"2016-10-07","close":28.515,"eps_ttm":2.31,"pe":12.34},{"date":"2016-10-14","close":29.4075,"eps_ttm":2.31,"pe":12.73},{"date":"2016-10-21","close":29.15,"eps_ttm":2.31,"pe":12.62},{"date":"2016-10-28","close":28.43,"eps_ttm":2.08,"pe":13.67},{"date":"2016-11-04","close":27.21,"eps_ttm":2.08,"pe":13.08},{"date":"2016-11-11","close":27.1075,"eps_ttm":2.08,"pe":13.03},{"date":"2016-11-18","close":27.515,"eps_ttm":2.08,"pe":13.23},{"date":"2016-11-25","close":27.9475,"eps_ttm":2.08,"pe":13.44},{"date":"2016-12-02","close":27.475,"eps_ttm":2.08,"pe":13.21},{"date":"2016-12-09","close":28.4875,"eps_ttm":2.08,"pe":13.7},{"date":"2016-12-16","close":28.9925,"eps_ttm":2.08,"pe":13.94},{"date":"2016-12-23","close":29.13,"eps_ttm":2.08,"pe":14},{"date":"2016-12-30","close":28.955,"eps_ttm":2.08,"pe":13.92},{"date":"2017-01-06","close":29.4775,"eps_ttm":2.08,"pe":14.17},{"date":"2017-01-13","close":29.76,"eps_ttm":2.08,"pe":14.31},{"date":"2017-01-20","close":30,"eps_ttm":2.08,"pe":14.42},{"date":"2017-01-27","close":30.4875,"eps_ttm":2.08,"pe":14.66},{"date":"2017-02-03","close":32.27,"eps_ttm":2.08,"pe":15.51},{"date":"2017-02-10","close":33.03,"eps_ttm":2.08,"pe":15.88},{"date":"2017-02-17","close":33.93,"eps_ttm":2.08,"pe":16.31},{"date":"2017-02-24","close":34.165,"eps_ttm":2.08,"pe":16.43},{"date":"2017-03-03","close":34.945,"eps_ttm":2.08,"pe":16.8},{"date":"2017-03-10","close":34.785,"eps_ttm":2.08,"pe":16.72},{"date":"2017-03-17","close":34.9975,"eps_ttm":2.08,"pe":16.83},{"date":"2017-03-24","close":35.16,"eps_ttm":2.08,"pe":16.9},{"date":"2017-03-31","close":35.915,"eps_ttm":2.08,"pe":17.27},{"date":"2017-04-07","close":35.835,"eps_ttm":2.08,"pe":17.23},{"date":"2017-04-13","close":35.2625,"eps_ttm":2.08,"pe":16.95},{"date":"2017-04-21","close":35.5675,"eps_ttm":2.08,"pe":17.1},{"date":"2017-04-28","close":35.9125,"eps_ttm":2.08,"pe":17.27},{"date":"2017-05-05","close":37.24,"eps_ttm":2.08,"pe":17.9},{"date":"2017-05-12","close":39.025,"eps_ttm":2.08,"pe":18.76},{"date":"2017-05-19","close":38.265,"eps_ttm":2.08,"pe":18.4},{"date":"2017-05-26","close":38.4025,"eps_ttm":2.08,"pe":18.46},{"date":"2017-06-02","close":38.8625,"eps_ttm":2.08,"pe":18.68},{"date":"2017-06-09","close":37.245,"eps_ttm":2.08,"pe":17.91},{"date":"2017-06-16","close":35.5675,"eps_ttm":2.08,"pe":17.1},{"date":"2017-06-23","close":36.57,"eps_ttm":2.08,"pe":17.58},{"date":"2017-06-30","close":36.005,"eps_ttm":2.08,"pe":17.31},{"date":"2017-07-07","close":36.045,"eps_ttm":2.08,"pe":17.33},{"date":"2017-07-14","close":37.26,"eps_ttm":2.08,"pe":17.91},{"date":"2017-07-21","close":37.5675,"eps_ttm":2.08,"pe":18.06},{"date":"2017-07-28","close":37.375,"eps_ttm":2.08,"pe":17.97},{"date":"2017-08-04","close":39.0975,"eps_ttm":2.08,"pe":18.8},{"date":"2017-08-11","close":39.37,"eps_ttm":2.08,"pe":18.93},{"date":"2017-08-18","close":39.375,"eps_ttm":2.08,"pe":18.93},{"date":"2017-08-25","close":39.965,"eps_ttm":2.08,"pe":19.21},{"date":"2017-09-01","close":41.0125,"eps_ttm":2.08,"pe":19.72},{"date":"2017-09-08","close":39.6575,"eps_ttm":2.08,"pe":19.07},{"date":"2017-09-15","close":39.97,"eps_ttm":2.08,"pe":19.22},{"date":"2017-09-22","close":37.9725,"eps_ttm":2.08,"pe":18.26},{"date":"2017-09-29","close":38.53,"eps_ttm":2.08,"pe":18.52},{"date":"2017-10-06","close":38.825,"eps_ttm":2.08,"pe":18.67},{"date":"2017-10-13","close":39.2475,"eps_ttm":2.08,"pe":18.87},{"date":"2017-10-20","close":39.0625,"eps_ttm":2.08,"pe":18.78},{"date":"2017-10-27","close":40.7625,"eps_ttm":2.08,"pe":19.6},{"date":"2017-11-03","close":43.125,"eps_ttm":2.3,"pe":18.75},{"date":"2017-11-10","close":43.6675,"eps_ttm":2.3,"pe":18.99},{"date":"2017-11-17","close":42.5375,"eps_ttm":2.3,"pe":18.49},{"date":"2017-11-24","close":43.7425,"eps_ttm":2.3,"pe":19.02},{"date":"2017-12-01","close":42.7625,"eps_ttm":2.3,"pe":18.59},{"date":"2017-12-08","close":42.3425,"eps_ttm":2.3,"pe":18.41},{"date":"2017-12-15","close":43.4925,"eps_ttm":2.3,"pe":18.91},{"date":"2017-12-22","close":43.7525,"eps_ttm":2.3,"pe":19.02},{"date":"2017-12-29","close":42.3075,"eps_ttm":2.3,"pe":18.39},{"date":"2018-01-05","close":43.75,"eps_ttm":2.3,"pe":19.02},{"date":"2018-01-12","close":44.2725,"eps_ttm":2.3,"pe":19.25},{"date":"2018-01-19","close":44.615,"eps_ttm":2.3,"pe":19.4},{"date":"2018-01-26","close":42.8775,"eps_ttm":2.3,"pe":18.64},{"date":"2018-02-02","close":40.125,"eps_ttm":2.3,"pe":17.45},{"date":"2018-02-09","close":39.1025,"eps_ttm":2.3,"pe":17},{"date":"2018-02-16","close":43.1075,"eps_ttm":2.3,"pe":18.74},{"date":"2018-02-23","close":43.875,"eps_ttm":2.3,"pe":19.08},{"date":"2018-03-02","close":44.0525,"eps_ttm":2.3,"pe":19.15},{"date":"2018-03-09","close":44.995,"eps_ttm":2.3,"pe":19.56},{"date":"2018-03-16","close":44.505,"eps_ttm":2.3,"pe":19.35},{"date":"2018-03-23","close":41.235,"eps_ttm":2.3,"pe":17.93},{"date":"2018-03-29","close":41.945,"eps_ttm":2.3,"pe":18.24},{"date":"2018-04-06","close":42.095,"eps_ttm":2.3,"pe":18.3},{"date":"2018-04-13","close":43.6825,"eps_ttm":2.3,"pe":18.99},{"date":"2018-04-20","close":41.43,"eps_ttm":2.3,"pe":18.01},{"date":"2018-04-27","close":40.58,"eps_ttm":2.3,"pe":17.64},{"date":"2018-05-04","close":45.9575,"eps_ttm":2.3,"pe":19.98},{"date":"2018-05-11","close":47.1475,"eps_ttm":2.3,"pe":20.5},{"date":"2018-05-18","close":46.5775,"eps_ttm":2.3,"pe":20.25},{"date":"2018-05-25","close":47.145,"eps_ttm":2.3,"pe":20.5},{"date":"2018-06-01","close":47.56,"eps_ttm":2.3,"pe":20.68},{"date":"2018-06-08","close":47.925,"eps_ttm":2.3,"pe":20.84},{"date":"2018-06-15","close":47.21,"eps_ttm":2.3,"pe":20.53},{"date":"2018-06-22","close":46.23,"eps_ttm":2.3,"pe":20.1},{"date":"2018-06-29","close":46.2775,"eps_ttm":2.3,"pe":20.12},{"date":"2018-07-06","close":46.9925,"eps_ttm":2.3,"pe":20.43},{"date":"2018-07-13","close":47.8325,"eps_ttm":2.3,"pe":20.8},{"date":"2018-07-20","close":47.86,"eps_ttm":2.3,"pe":20.81},{"date":"2018-07-27","close":47.745,"eps_ttm":2.3,"pe":20.76},{"date":"2018-08-03","close":51.9975,"eps_ttm":2.3,"pe":22.61},{"date":"2018-08-10","close":51.8825,"eps_ttm":2.3,"pe":22.56},{"date":"2018-08-17","close":54.395,"eps_ttm":2.3,"pe":23.65},{"date":"2018-08-24","close":54.04,"eps_ttm":2.3,"pe":23.5},{"date":"2018-08-31","close":56.9075,"eps_ttm":2.3,"pe":24.74},{"date":"2018-09-07","close":55.325,"eps_ttm":2.3,"pe":24.05},{"date":"2018-09-14","close":55.96,"eps_ttm":2.3,"pe":24.33},{"date":"2018-09-21","close":54.415,"eps_ttm":2.3,"pe":23.66},{"date":"2018-09-28","close":56.435,"eps_ttm":2.3,"pe":24.54},{"date":"2018-10-05","close":56.0725,"eps_ttm":2.3,"pe":24.38},{"date":"2018-10-12","close":55.5275,"eps_ttm":2.3,"pe":24.14},{"date":"2018-10-19","close":54.8275,"eps_ttm":2.3,"pe":23.84},{"date":"2018-10-26","close":54.075,"eps_ttm":2.3,"pe":23.51},{"date":"2018-11-02","close":51.87,"eps_ttm":2.98,"pe":17.41},{"date":"2018-11-09","close":51.1175,"eps_ttm":2.98,"pe":17.15},{"date":"2018-11-16","close":48.3825,"eps_ttm":2.98,"pe":16.24},{"date":"2018-11-23","close":43.0725,"eps_ttm":2.98,"pe":14.45},{"date":"2018-11-30","close":44.645,"eps_ttm":2.98,"pe":14.98},{"date":"2018-12-07","close":42.1225,"eps_ttm":2.98,"pe":14.14},{"date":"2018-12-14","close":41.37,"eps_ttm":2.98,"pe":13.88},{"date":"2018-12-21","close":37.6825,"eps_ttm":2.98,"pe":12.65},{"date":"2018-12-28","close":39.0575,"eps_ttm":2.98,"pe":13.11},{"date":"2019-01-04","close":37.065,"eps_ttm":2.98,"pe":12.44},{"date":"2019-01-11","close":38.0725,"eps_ttm":2.98,"pe":12.78},{"date":"2019-01-18","close":39.205,"eps_ttm":2.98,"pe":13.16},{"date":"2019-01-25","close":39.44,"eps_ttm":2.98,"pe":13.23},{"date":"2019-02-01","close":41.63,"eps_ttm":2.98,"pe":13.97},{"date":"2019-02-08","close":42.6025,"eps_ttm":2.98,"pe":14.3},{"date":"2019-02-15","close":42.605,"eps_ttm":2.98,"pe":14.3},{"date":"2019-02-22","close":43.2425,"eps_ttm":2.98,"pe":14.51},{"date":"2019-03-01","close":43.7425,"eps_ttm":2.98,"pe":14.68},{"date":"2019-03-08","close":43.2275,"eps_ttm":2.98,"pe":14.51},{"date":"2019-03-15","close":46.53,"eps_ttm":2.98,"pe":15.61},{"date":"2019-03-22","close":47.7625,"eps_ttm":2.98,"pe":16.03},{"date":"2019-03-29","close":47.4875,"eps_ttm":2.98,"pe":15.94},{"date":"2019-04-05","close":49.25,"eps_ttm":2.98,"pe":16.53},{"date":"2019-04-12","close":49.7175,"eps_ttm":2.98,"pe":16.68},{"date":"2019-04-18","close":50.965,"eps_ttm":2.98,"pe":17.1},{"date":"2019-04-26","close":51.075,"eps_ttm":2.98,"pe":17.14},{"date":"2019-05-03","close":52.9375,"eps_ttm":2.98,"pe":17.76},{"date":"2019-05-10","close":49.295,"eps_ttm":2.98,"pe":16.54},{"date":"2019-05-17","close":47.25,"eps_ttm":2.98,"pe":15.86},{"date":"2019-05-24","close":44.7425,"eps_ttm":2.98,"pe":15.01},{"date":"2019-05-31","close":43.7675,"eps_ttm":2.98,"pe":14.69},{"date":"2019-06-07","close":47.5375,"eps_ttm":2.98,"pe":15.95},{"date":"2019-06-14","close":48.185,"eps_ttm":2.98,"pe":16.17},{"date":"2019-06-21","close":49.695,"eps_ttm":2.98,"pe":16.68},{"date":"2019-06-28","close":49.48,"eps_ttm":2.98,"pe":16.6},{"date":"2019-07-05","close":51.0575,"eps_ttm":2.98,"pe":17.13},{"date":"2019-07-12","close":50.825,"eps_ttm":2.98,"pe":17.06},{"date":"2019-07-19","close":50.6475,"eps_ttm":2.98,"pe":17},{"date":"2019-07-26","close":51.935,"eps_ttm":2.98,"pe":17.43},{"date":"2019-08-02","close":51.005,"eps_ttm":2.98,"pe":17.12},{"date":"2019-08-09","close":50.2475,"eps_ttm":2.98,"pe":16.86},{"date":"2019-08-16","close":51.625,"eps_ttm":2.98,"pe":17.32},{"date":"2019-08-23","close":50.66,"eps_ttm":2.98,"pe":17},{"date":"2019-08-30","close":52.185,"eps_ttm":2.98,"pe":17.51},{"date":"2019-09-06","close":53.315,"eps_ttm":2.98,"pe":17.89},{"date":"2019-09-13","close":54.6875,"eps_ttm":2.98,"pe":18.35},{"date":"2019-09-20","close":54.4325,"eps_ttm":2.98,"pe":18.27},{"date":"2019-09-27","close":54.705,"eps_ttm":2.98,"pe":18.36},{"date":"2019-10-04","close":56.7525,"eps_ttm":2.98,"pe":19.04},{"date":"2019-10-11","close":59.0525,"eps_ttm":2.98,"pe":19.82},{"date":"2019-10-18","close":59.1025,"eps_ttm":2.98,"pe":19.83},{"date":"2019-10-25","close":61.645,"eps_ttm":2.98,"pe":20.69},{"date":"2019-11-01","close":63.955,"eps_ttm":2.97,"pe":21.53},{"date":"2019-11-08","close":65.035,"eps_ttm":2.97,"pe":21.9},{"date":"2019-11-15","close":66.44,"eps_ttm":2.97,"pe":22.37},{"date":"2019-11-22","close":65.445,"eps_ttm":2.97,"pe":22.04},{"date":"2019-11-29","close":66.8125,"eps_ttm":2.97,"pe":22.5},{"date":"2019-12-06","close":67.6775,"eps_ttm":2.97,"pe":22.79},{"date":"2019-12-13","close":68.7875,"eps_ttm":2.97,"pe":23.16},{"date":"2019-12-20","close":69.86,"eps_ttm":2.97,"pe":23.52},{"date":"2019-12-27","close":72.45,"eps_ttm":2.97,"pe":24.39},{"date":"2020-01-03","close":74.3575,"eps_ttm":2.97,"pe":25.04},{"date":"2020-01-10","close":77.5825,"eps_ttm":2.97,"pe":26.12},{"date":"2020-01-17","close":79.6825,"eps_ttm":2.97,"pe":26.83},{"date":"2020-01-24","close":79.5775,"eps_ttm":2.97,"pe":26.79},{"date":"2020-01-31","close":77.3775,"eps_ttm":2.97,"pe":26.05},{"date":"2020-02-07","close":80.0075,"eps_ttm":2.97,"pe":26.94},{"date":"2020-02-14","close":81.2375,"eps_ttm":2.97,"pe":27.35},{"date":"2020-02-21","close":78.2625,"eps_ttm":2.97,"pe":26.35},{"date":"2020-02-28","close":68.34,"eps_ttm":2.97,"pe":23.01},{"date":"2020-03-06","close":72.2575,"eps_ttm":2.97,"pe":24.33},{"date":"2020-03-13","close":69.4925,"eps_ttm":2.97,"pe":23.4},{"date":"2020-03-20","close":57.31,"eps_ttm":2.97,"pe":19.3},{"date":"2020-03-27","close":61.935,"eps_ttm":2.97,"pe":20.85},{"date":"2020-04-03","close":60.3525,"eps_ttm":2.97,"pe":20.32},{"date":"2020-04-09","close":66.9975,"eps_ttm":2.97,"pe":22.56},{"date":"2020-04-17","close":70.7,"eps_ttm":2.97,"pe":23.8},{"date":"2020-04-24","close":70.7425,"eps_ttm":2.97,"pe":23.82},{"date":"2020-05-01","close":72.2675,"eps_ttm":2.97,"pe":24.33},{"date":"2020-05-08","close":77.5325,"eps_ttm":2.97,"pe":26.11},{"date":"2020-05-15","close":76.9275,"eps_ttm":2.97,"pe":25.9},{"date":"2020-05-22","close":79.7225,"eps_ttm":2.97,"pe":26.84},{"date":"2020-05-29","close":79.485,"eps_ttm":2.97,"pe":26.76},{"date":"2020-06-05","close":82.875,"eps_ttm":2.97,"pe":27.9},{"date":"2020-06-12","close":84.7,"eps_ttm":2.97,"pe":28.52},{"date":"2020-06-19","close":87.43,"eps_ttm":2.97,"pe":29.44},{"date":"2020-06-26","close":88.4075,"eps_ttm":2.97,"pe":29.77},{"date":"2020-07-02","close":91.0275,"eps_ttm":2.97,"pe":30.65},{"date":"2020-07-10","close":95.92,"eps_ttm":2.97,"pe":32.3},{"date":"2020-07-17","close":96.3275,"eps_ttm":2.97,"pe":32.43},{"date":"2020-07-24","close":92.615,"eps_ttm":2.97,"pe":31.18},{"date":"2020-07-31","close":106.26,"eps_ttm":2.97,"pe":35.78},{"date":"2020-08-07","close":111.1125,"eps_ttm":2.97,"pe":37.41},{"date":"2020-08-14","close":114.9075,"eps_ttm":2.97,"pe":38.69},{"date":"2020-08-21","close":124.37,"eps_ttm":2.97,"pe":41.88},{"date":"2020-08-28","close":124.8075,"eps_ttm":2.97,"pe":42.02},{"date":"2020-09-04","close":120.96,"eps_ttm":2.97,"pe":40.73},{"date":"2020-09-11","close":112,"eps_ttm":2.97,"pe":37.71},{"date":"2020-09-18","close":106.84,"eps_ttm":2.97,"pe":35.97},{"date":"2020-09-25","close":112.28,"eps_ttm":2.97,"pe":37.8},{"date":"2020-10-02","close":113.02,"eps_ttm":2.97,"pe":38.05},{"date":"2020-10-09","close":116.97,"eps_ttm":2.97,"pe":39.38},{"date":"2020-10-16","close":119.02,"eps_ttm":2.97,"pe":40.07},{"date":"2020-10-23","close":115.04,"eps_ttm":2.97,"pe":38.73},{"date":"2020-10-30","close":108.86,"eps_ttm":3.28,"pe":33.19},{"date":"2020-11-06","close":118.69,"eps_ttm":3.28,"pe":36.19},{"date":"2020-11-13","close":119.26,"eps_ttm":3.28,"pe":36.36},{"date":"2020-11-20","close":117.34,"eps_ttm":3.28,"pe":35.77},{"date":"2020-11-27","close":116.59,"eps_ttm":3.28,"pe":35.55},{"date":"2020-12-04","close":122.25,"eps_ttm":3.28,"pe":37.27},{"date":"2020-12-11","close":122.41,"eps_ttm":3.28,"pe":37.32},{"date":"2020-12-18","close":126.66,"eps_ttm":3.28,"pe":38.62},{"date":"2020-12-24","close":131.97,"eps_ttm":3.28,"pe":40.23},{"date":"2020-12-31","close":132.69,"eps_ttm":3.28,"pe":40.45},{"date":"2021-01-08","close":132.05,"eps_ttm":3.28,"pe":40.26},{"date":"2021-01-15","close":127.14,"eps_ttm":3.28,"pe":38.76},{"date":"2021-01-22","close":139.07,"eps_ttm":3.28,"pe":42.4},{"date":"2021-01-29","close":131.96,"eps_ttm":3.28,"pe":40.23},{"date":"2021-02-05","close":136.76,"eps_ttm":3.28,"pe":41.7},{"date":"2021-02-12","close":135.37,"eps_ttm":3.28,"pe":41.27},{"date":"2021-02-19","close":129.87,"eps_ttm":3.28,"pe":39.59},{"date":"2021-02-26","close":121.26,"eps_ttm":3.28,"pe":36.97},{"date":"2021-03-05","close":121.42,"eps_ttm":3.28,"pe":37.02},{"date":"2021-03-12","close":121.03,"eps_ttm":3.28,"pe":36.9},{"date":"2021-03-19","close":119.99,"eps_ttm":3.28,"pe":36.58},{"date":"2021-03-26","close":121.21,"eps_ttm":3.28,"pe":36.95},{"date":"2021-04-01","close":123,"eps_ttm":3.28,"pe":37.5},{"date":"2021-04-09","close":133,"eps_ttm":3.28,"pe":40.55},{"date":"2021-04-16","close":134.16,"eps_ttm":3.28,"pe":40.9},{"date":"2021-04-23","close":134.32,"eps_ttm":3.28,"pe":40.95},{"date":"2021-04-30","close":131.46,"eps_ttm":3.28,"pe":40.08},{"date":"2021-05-07","close":130.21,"eps_ttm":3.28,"pe":39.7},{"date":"2021-05-14","close":127.45,"eps_ttm":3.28,"pe":38.86},{"date":"2021-05-21","close":125.43,"eps_ttm":3.28,"pe":38.24},{"date":"2021-05-28","close":124.61,"eps_ttm":3.28,"pe":37.99},{"date":"2021-06-04","close":125.89,"eps_ttm":3.28,"pe":38.38},{"date":"2021-06-11","close":127.35,"eps_ttm":3.28,"pe":38.83},{"date":"2021-06-18","close":130.46,"eps_ttm":3.28,"pe":39.77},{"date":"2021-06-25","close":133.11,"eps_ttm":3.28,"pe":40.58},{"date":"2021-07-02","close":139.96,"eps_ttm":3.28,"pe":42.67},{"date":"2021-07-09","close":145.11,"eps_ttm":3.28,"pe":44.24},{"date":"2021-07-16","close":146.39,"eps_ttm":3.28,"pe":44.63},{"date":"2021-07-23","close":148.56,"eps_ttm":3.28,"pe":45.29},{"date":"2021-07-30","close":145.86,"eps_ttm":3.28,"pe":44.47},{"date":"2021-08-06","close":146.14,"eps_ttm":3.28,"pe":44.55},{"date":"2021-08-13","close":149.1,"eps_ttm":3.28,"pe":45.46},{"date":"2021-08-20","close":148.19,"eps_ttm":3.28,"pe":45.18},{"date":"2021-08-27","close":148.6,"eps_ttm":3.28,"pe":45.3},{"date":"2021-09-03","close":154.3,"eps_ttm":3.28,"pe":47.04},{"date":"2021-09-10","close":148.97,"eps_ttm":3.28,"pe":45.42},{"date":"2021-09-17","close":146.06,"eps_ttm":3.28,"pe":44.53},{"date":"2021-09-24","close":146.92,"eps_ttm":3.28,"pe":44.79},{"date":"2021-10-01","close":142.65,"eps_ttm":3.28,"pe":43.49},{"date":"2021-10-08","close":142.9,"eps_ttm":3.28,"pe":43.57},{"date":"2021-10-15","close":144.84,"eps_ttm":3.28,"pe":44.16},{"date":"2021-10-22","close":148.69,"eps_ttm":3.28,"pe":45.33},{"date":"2021-10-29","close":149.8,"eps_ttm":5.61,"pe":26.7},{"date":"2021-11-05","close":151.28,"eps_ttm":5.61,"pe":26.97},{"date":"2021-11-12","close":149.99,"eps_ttm":5.61,"pe":26.74},{"date":"2021-11-19","close":160.55,"eps_ttm":5.61,"pe":28.62},{"date":"2021-11-26","close":156.81,"eps_ttm":5.61,"pe":27.95},{"date":"2021-12-03","close":161.84,"eps_ttm":5.61,"pe":28.85},{"date":"2021-12-10","close":179.45,"eps_ttm":5.61,"pe":31.99},{"date":"2021-12-17","close":171.14,"eps_ttm":5.61,"pe":30.51},{"date":"2021-12-23","close":176.28,"eps_ttm":5.61,"pe":31.42},{"date":"2021-12-31","close":177.57,"eps_ttm":5.61,"pe":31.65},{"date":"2022-01-07","close":172.17,"eps_ttm":5.61,"pe":30.69},{"date":"2022-01-14","close":173.07,"eps_ttm":5.61,"pe":30.85},{"date":"2022-01-21","close":162.41,"eps_ttm":5.61,"pe":28.95},{"date":"2022-01-28","close":170.33,"eps_ttm":5.61,"pe":30.36},{"date":"2022-02-04","close":172.39,"eps_ttm":5.61,"pe":30.73},{"date":"2022-02-11","close":168.64,"eps_ttm":5.61,"pe":30.06},{"date":"2022-02-18","close":167.3,"eps_ttm":5.61,"pe":29.82},{"date":"2022-02-25","close":164.85,"eps_ttm":5.61,"pe":29.39},{"date":"2022-03-04","close":163.17,"eps_ttm":5.61,"pe":29.09},{"date":"2022-03-11","close":154.73,"eps_ttm":5.61,"pe":27.58},{"date":"2022-03-18","close":163.98,"eps_ttm":5.61,"pe":29.23},{"date":"2022-03-25","close":174.72,"eps_ttm":5.61,"pe":31.14},{"date":"2022-04-01","close":174.31,"eps_ttm":5.61,"pe":31.07},{"date":"2022-04-08","close":170.09,"eps_ttm":5.61,"pe":30.32},{"date":"2022-04-14","close":165.29,"eps_ttm":5.61,"pe":29.46},{"date":"2022-04-22","close":161.79,"eps_ttm":5.61,"pe":28.84},{"date":"2022-04-29","close":157.65,"eps_ttm":5.61,"pe":28.1},{"date":"2022-05-06","close":157.28,"eps_ttm":5.61,"pe":28.04},{"date":"2022-05-13","close":147.11,"eps_ttm":5.61,"pe":26.22},{"date":"2022-05-20","close":137.59,"eps_ttm":5.61,"pe":24.53},{"date":"2022-05-27","close":149.64,"eps_ttm":5.61,"pe":26.67},{"date":"2022-06-03","close":145.38,"eps_ttm":5.61,"pe":25.91},{"date":"2022-06-10","close":137.13,"eps_ttm":5.61,"pe":24.44},{"date":"2022-06-17","close":131.56,"eps_ttm":5.61,"pe":23.45},{"date":"2022-06-24","close":141.66,"eps_ttm":5.61,"pe":25.25},{"date":"2022-07-01","close":138.93,"eps_ttm":5.61,"pe":24.76},{"date":"2022-07-08","close":147.04,"eps_ttm":5.61,"pe":26.21},{"date":"2022-07-15","close":150.17,"eps_ttm":5.61,"pe":26.77},{"date":"2022-07-22","close":154.09,"eps_ttm":5.61,"pe":27.47},{"date":"2022-07-29","close":162.51,"eps_ttm":5.61,"pe":28.97},{"date":"2022-08-05","close":165.35,"eps_ttm":5.61,"pe":29.47},{"date":"2022-08-12","close":172.1,"eps_ttm":5.61,"pe":30.68},{"date":"2022-08-19","close":171.52,"eps_ttm":5.61,"pe":30.57},{"date":"2022-08-26","close":163.62,"eps_ttm":5.61,"pe":29.17},{"date":"2022-09-02","close":155.81,"eps_ttm":5.61,"pe":27.77},{"date":"2022-09-09","close":157.37,"eps_ttm":5.61,"pe":28.05},{"date":"2022-09-16","close":150.7,"eps_ttm":5.61,"pe":26.86},{"date":"2022-09-23","close":150.43,"eps_ttm":5.61,"pe":26.81},{"date":"2022-09-30","close":138.2,"eps_ttm":5.61,"pe":24.63},{"date":"2022-10-07","close":140.09,"eps_ttm":5.61,"pe":24.97},{"date":"2022-10-14","close":138.38,"eps_ttm":5.61,"pe":24.67},{"date":"2022-10-21","close":147.27,"eps_ttm":5.61,"pe":26.25},{"date":"2022-10-28","close":155.74,"eps_ttm":6.11,"pe":25.49},{"date":"2022-11-04","close":138.38,"eps_ttm":6.11,"pe":22.65},{"date":"2022-11-11","close":149.7,"eps_ttm":6.11,"pe":24.5},{"date":"2022-11-18","close":151.29,"eps_ttm":6.11,"pe":24.76},{"date":"2022-11-25","close":148.11,"eps_ttm":6.11,"pe":24.24},{"date":"2022-12-02","close":147.81,"eps_ttm":6.11,"pe":24.19},{"date":"2022-12-09","close":142.16,"eps_ttm":6.11,"pe":23.27},{"date":"2022-12-16","close":134.51,"eps_ttm":6.11,"pe":22.01},{"date":"2022-12-23","close":131.86,"eps_ttm":6.11,"pe":21.58},{"date":"2022-12-30","close":129.93,"eps_ttm":6.11,"pe":21.27},{"date":"2023-01-06","close":129.62,"eps_ttm":6.11,"pe":21.21},{"date":"2023-01-13","close":134.76,"eps_ttm":6.11,"pe":22.06},{"date":"2023-01-20","close":137.87,"eps_ttm":6.11,"pe":22.56},{"date":"2023-01-27","close":145.93,"eps_ttm":6.11,"pe":23.88},{"date":"2023-02-03","close":154.5,"eps_ttm":6.11,"pe":25.29},{"date":"2023-02-10","close":151.01,"eps_ttm":6.11,"pe":24.72},{"date":"2023-02-17","close":152.55,"eps_ttm":6.11,"pe":24.97},{"date":"2023-02-24","close":146.71,"eps_ttm":6.11,"pe":24.01},{"date":"2023-03-03","close":151.03,"eps_ttm":6.11,"pe":24.72},{"date":"2023-03-10","close":148.5,"eps_ttm":6.11,"pe":24.3},{"date":"2023-03-17","close":155,"eps_ttm":6.11,"pe":25.37},{"date":"2023-03-24","close":160.25,"eps_ttm":6.11,"pe":26.23},{"date":"2023-03-31","close":164.9,"eps_ttm":6.11,"pe":26.99},{"date":"2023-04-06","close":164.66,"eps_ttm":6.11,"pe":26.95},{"date":"2023-04-14","close":165.21,"eps_ttm":6.11,"pe":27.04},{"date":"2023-04-21","close":165.02,"eps_ttm":6.11,"pe":27.01},{"date":"2023-04-28","close":169.68,"eps_ttm":6.11,"pe":27.77},{"date":"2023-05-05","close":173.57,"eps_ttm":6.11,"pe":28.41},{"date":"2023-05-12","close":172.57,"eps_ttm":6.11,"pe":28.24},{"date":"2023-05-19","close":175.16,"eps_ttm":6.11,"pe":28.67},{"date":"2023-05-26","close":175.43,"eps_ttm":6.11,"pe":28.71},{"date":"2023-06-02","close":180.95,"eps_ttm":6.11,"pe":29.62},{"date":"2023-06-09","close":180.96,"eps_ttm":6.11,"pe":29.62},{"date":"2023-06-16","close":184.92,"eps_ttm":6.11,"pe":30.27},{"date":"2023-06-23","close":186.68,"eps_ttm":6.11,"pe":30.55},{"date":"2023-06-30","close":193.97,"eps_ttm":6.11,"pe":31.75},{"date":"2023-07-07","close":190.68,"eps_ttm":6.11,"pe":31.21},{"date":"2023-07-14","close":190.69,"eps_ttm":6.11,"pe":31.21},{"date":"2023-07-21","close":191.94,"eps_ttm":6.11,"pe":31.41},{"date":"2023-07-28","close":195.83,"eps_ttm":6.11,"pe":32.05},{"date":"2023-08-04","close":181.99,"eps_ttm":6.11,"pe":29.79},{"date":"2023-08-11","close":177.79,"eps_ttm":6.11,"pe":29.1},{"date":"2023-08-18","close":174.49,"eps_ttm":6.11,"pe":28.56},{"date":"2023-08-25","close":178.61,"eps_ttm":6.11,"pe":29.23},{"date":"2023-09-01","close":189.46,"eps_ttm":6.11,"pe":31.01},{"date":"2023-09-08","close":178.18,"eps_ttm":6.11,"pe":29.16},{"date":"2023-09-15","close":175.01,"eps_ttm":6.11,"pe":28.64},{"date":"2023-09-22","close":174.79,"eps_ttm":6.11,"pe":28.61},{"date":"2023-09-29","close":171.21,"eps_ttm":6.11,"pe":28.02},{"date":"2023-10-06","close":177.49,"eps_ttm":6.11,"pe":29.05},{"date":"2023-10-13","close":178.85,"eps_ttm":6.11,"pe":29.27},{"date":"2023-10-20","close":172.88,"eps_ttm":6.11,"pe":28.29},{"date":"2023-10-27","close":168.22,"eps_ttm":6.11,"pe":27.53},{"date":"2023-11-03","close":176.65,"eps_ttm":6.13,"pe":28.82},{"date":"2023-11-10","close":186.4,"eps_ttm":6.13,"pe":30.41},{"date":"2023-11-17","close":189.69,"eps_ttm":6.13,"pe":30.94},{"date":"2023-11-24","close":189.97,"eps_ttm":6.13,"pe":30.99},{"date":"2023-12-01","close":191.24,"eps_ttm":6.13,"pe":31.2},{"date":"2023-12-08","close":195.71,"eps_ttm":6.13,"pe":31.93},{"date":"2023-12-15","close":197.57,"eps_ttm":6.13,"pe":32.23},{"date":"2023-12-22","close":193.6,"eps_ttm":6.13,"pe":31.58},{"date":"2023-12-29","close":192.53,"eps_ttm":6.13,"pe":31.41},{"date":"2024-01-05","close":181.18,"eps_ttm":6.13,"pe":29.56},{"date":"2024-01-12","close":185.92,"eps_ttm":6.13,"pe":30.33},{"date":"2024-01-19","close":191.56,"eps_ttm":6.13,"pe":31.25},{"date":"2024-01-26","close":192.42,"eps_ttm":6.13,"pe":31.39},{"date":"2024-02-02","close":185.85,"eps_ttm":6.13,"pe":30.32},{"date":"2024-02-09","close":188.85,"eps_ttm":6.13,"pe":30.81},{"date":"2024-02-16","close":182.31,"eps_ttm":6.13,"pe":29.74},{"date":"2024-02-23","close":182.52,"eps_ttm":6.13,"pe":29.77},{"date":"2024-03-01","close":179.66,"eps_ttm":6.13,"pe":29.31},{"date":"2024-03-08","close":170.73,"eps_ttm":6.13,"pe":27.85},{"date":"2024-03-15","close":172.62,"eps_ttm":6.13,"pe":28.16},{"date":"2024-03-22","close":172.28,"eps_ttm":6.13,"pe":28.1},{"date":"2024-03-28","close":171.48,"eps_ttm":6.13,"pe":27.97},{"date":"2024-04-05","close":169.58,"eps_ttm":6.13,"pe":27.66},{"date":"2024-04-12","close":176.55,"eps_ttm":6.13,"pe":28.8},{"date":"2024-04-19","close":165,"eps_ttm":6.13,"pe":26.92},{"date":"2024-04-26","close":169.3,"eps_ttm":6.13,"pe":27.62},{"date":"2024-05-03","close":183.38,"eps_ttm":6.13,"pe":29.92},{"date":"2024-05-10","close":183.05,"eps_ttm":6.13,"pe":29.86},{"date":"2024-05-17","close":189.87,"eps_ttm":6.13,"pe":30.97},{"date":"2024-05-24","close":189.98,"eps_ttm":6.13,"pe":30.99},{"date":"2024-05-31","close":192.25,"eps_ttm":6.13,"pe":31.36},{"date":"2024-06-07","close":196.89,"eps_ttm":6.13,"pe":32.12},{"date":"2024-06-14","close":212.49,"eps_ttm":6.13,"pe":34.66},{"date":"2024-06-21","close":207.49,"eps_ttm":6.13,"pe":33.85},{"date":"2024-06-28","close":210.62,"eps_ttm":6.13,"pe":34.36},{"date":"2024-07-05","close":226.34,"eps_ttm":6.13,"pe":36.92},{"date":"2024-07-12","close":230.54,"eps_ttm":6.13,"pe":37.61},{"date":"2024-07-19","close":224.31,"eps_ttm":6.13,"pe":36.59},{"date":"2024-07-26","close":217.96,"eps_ttm":6.13,"pe":35.56},{"date":"2024-08-02","close":219.86,"eps_ttm":6.13,"pe":35.87},{"date":"2024-08-09","close":216.24,"eps_ttm":6.13,"pe":35.28},{"date":"2024-08-16","close":226.05,"eps_ttm":6.13,"pe":36.88},{"date":"2024-08-23","close":226.84,"eps_ttm":6.13,"pe":37},{"date":"2024-08-30","close":229,"eps_ttm":6.13,"pe":37.36},{"date":"2024-09-06","close":220.82,"eps_ttm":6.13,"pe":36.02},{"date":"2024-09-13","close":222.5,"eps_ttm":6.13,"pe":36.3},{"date":"2024-09-20","close":228.2,"eps_ttm":6.13,"pe":37.23},{"date":"2024-09-27","close":227.79,"eps_ttm":6.13,"pe":37.16},{"date":"2024-10-04","close":226.8,"eps_ttm":6.13,"pe":37},{"date":"2024-10-11","close":227.55,"eps_ttm":6.13,"pe":37.12},{"date":"2024-10-18","close":235,"eps_ttm":6.13,"pe":38.34},{"date":"2024-10-25","close":231.41,"eps_ttm":6.13,"pe":37.75},{"date":"2024-11-01","close":222.91,"eps_ttm":6.08,"pe":36.66},{"date":"2024-11-08","close":226.96,"eps_ttm":6.08,"pe":37.33},{"date":"2024-11-15","close":225,"eps_ttm":6.08,"pe":37.01},{"date":"2024-11-22","close":229.87,"eps_ttm":6.08,"pe":37.81},{"date":"2024-11-29","close":237.33,"eps_ttm":6.08,"pe":39.03},{"date":"2024-12-06","close":242.84,"eps_ttm":6.08,"pe":39.94},{"date":"2024-12-13","close":248.13,"eps_ttm":6.08,"pe":40.81},{"date":"2024-12-20","close":254.49,"eps_ttm":6.08,"pe":41.86},{"date":"2024-12-27","close":255.59,"eps_ttm":6.08,"pe":42.04},{"date":"2025-01-03","close":243.36,"eps_ttm":6.08,"pe":40.03},{"date":"2025-01-10","close":236.85,"eps_ttm":6.08,"pe":38.96},{"date":"2025-01-17","close":229.98,"eps_ttm":6.08,"pe":37.83},{"date":"2025-01-24","close":222.78,"eps_ttm":6.08,"pe":36.64},{"date":"2025-01-31","close":236,"eps_ttm":6.08,"pe":38.82},{"date":"2025-02-07","close":227.63,"eps_ttm":6.08,"pe":37.44},{"date":"2025-02-14","close":244.6,"eps_ttm":6.08,"pe":40.23},{"date":"2025-02-21","close":245.55,"eps_ttm":6.08,"pe":40.39},{"date":"2025-02-28","close":241.84,"eps_ttm":6.08,"pe":39.78},{"date":"2025-03-07","close":239.07,"eps_ttm":6.08,"pe":39.32},{"date":"2025-03-14","close":213.49,"eps_ttm":6.08,"pe":35.11},{"date":"2025-03-21","close":218.27,"eps_ttm":6.08,"pe":35.9},{"date":"2025-03-28","close":217.9,"eps_ttm":6.08,"pe":35.84},{"date":"2025-04-04","close":188.38,"eps_ttm":6.08,"pe":30.98},{"date":"2025-04-11","close":198.15,"eps_ttm":6.08,"pe":32.59},{"date":"2025-04-17","close":196.98,"eps_ttm":6.08,"pe":32.4},{"date":"2025-04-25","close":209.28,"eps_ttm":6.08,"pe":34.42},{"date":"2025-05-02","close":205.35,"eps_ttm":6.08,"pe":33.77},{"date":"2025-05-09","close":198.53,"eps_ttm":6.08,"pe":32.65},{"date":"2025-05-16","close":211.26,"eps_ttm":6.08,"pe":34.75},{"date":"2025-05-23","close":195.27,"eps_ttm":6.08,"pe":32.12},{"date":"2025-05-30","close":200.85,"eps_ttm":6.08,"pe":33.03},{"date":"2025-06-06","close":203.92,"eps_ttm":6.08,"pe":33.54},{"date":"2025-06-13","close":196.45,"eps_ttm":6.08,"pe":32.31},{"date":"2025-06-20","close":201,"eps_ttm":6.08,"pe":33.06},{"date":"2025-06-27","close":201.08,"eps_ttm":6.08,"pe":33.07},{"date":"2025-07-03","close":213.55,"eps_ttm":6.08,"pe":35.12},{"date":"2025-07-11","close":211.16,"eps_ttm":6.08,"pe":34.73},{"date":"2025-07-18","close":211.18,"eps_ttm":6.08,"pe":34.73},{"date":"2025-07-25","close":213.88,"eps_ttm":6.08,"pe":35.18},{"date":"2025-08-01","close":202.38,"eps_ttm":6.08,"pe":33.29},{"date":"2025-08-08","close":229.35,"eps_ttm":6.08,"pe":37.72},{"date":"2025-08-15","close":231.59,"eps_ttm":6.08,"pe":38.09},{"date":"2025-08-22","close":227.76,"eps_ttm":6.08,"pe":37.46},{"date":"2025-08-29","close":232.14,"eps_ttm":6.08,"pe":38.18},{"date":"2025-09-05","close":239.69,"eps_ttm":6.08,"pe":39.42},{"date":"2025-09-12","close":234.07,"eps_ttm":6.08,"pe":38.5},{"date":"2025-09-19","close":245.5,"eps_ttm":6.08,"pe":40.38},{"date":"2025-09-26","close":255.46,"eps_ttm":6.08,"pe":42.02},{"date":"2025-10-03","close":258.02,"eps_ttm":6.08,"pe":42.44},{"date":"2025-10-10","close":245.27,"eps_ttm":6.08,"pe":40.34},{"date":"2025-10-17","close":252.29,"eps_ttm":6.08,"pe":41.5},{"date":"2025-10-24","close":262.82,"eps_ttm":6.08,"pe":43.23},{"date":"2025-10-31","close":270.37,"eps_ttm":7.46,"pe":36.24},{"date":"2025-11-07","close":268.47,"eps_ttm":7.46,"pe":35.99},{"date":"2025-11-14","close":272.41,"eps_ttm":7.46,"pe":36.52},{"date":"2025-11-21","close":271.49,"eps_ttm":7.46,"pe":36.39},{"date":"2025-11-28","close":278.85,"eps_ttm":7.46,"pe":37.38},{"date":"2025-12-05","close":278.78,"eps_ttm":7.46,"pe":37.37},{"date":"2025-12-12","close":278.28,"eps_ttm":7.46,"pe":37.3},{"date":"2025-12-19","close":273.67,"eps_ttm":7.46,"pe":36.68},{"date":"2025-12-26","close":273.4,"eps_ttm":7.46,"pe":36.65},{"date":"2026-01-02","close":271.01,"eps_ttm":7.46,"pe":36.33},{"date":"2026-01-09","close":259.37,"eps_ttm":7.46,"pe":34.77},{"date":"2026-01-16","close":255.53,"eps_ttm":7.46,"pe":34.25},{"date":"2026-01-23","close":248.04,"eps_ttm":7.46,"pe":33.25},{"date":"2026-01-30","close":259.48,"eps_ttm":7.46,"pe":34.78},{"date":"2026-02-06","close":278.12,"eps_ttm":7.46,"pe":37.28},{"date":"2026-02-13","close":255.78,"eps_ttm":7.46,"pe":34.29},{"date":"2026-02-20","close":264.58,"eps_ttm":7.46,"pe":35.47},{"date":"2026-02-27","close":264.18,"eps_ttm":7.46,"pe":35.41},{"date":"2026-03-06","close":257.46,"eps_ttm":7.46,"pe":34.51},{"date":"2026-03-13","close":250.12,"eps_ttm":7.46,"pe":33.53},{"date":"2026-03-20","close":247.99,"eps_ttm":7.46,"pe":33.24},{"date":"2026-03-27","close":248.8,"eps_ttm":7.46,"pe":33.35},{"date":"2026-04-02","close":255.92,"eps_ttm":7.46,"pe":34.31},{"date":"2026-04-10","close":260.48,"eps_ttm":7.46,"pe":34.92},{"date":"2026-04-17","close":270.23,"eps_ttm":7.46,"pe":36.22},{"date":"2026-04-24","close":271.06,"eps_ttm":7.46,"pe":36.34},{"date":"2026-05-01","close":280.14,"eps_ttm":7.46,"pe":37.55},{"date":"2026-05-08","close":293.32,"eps_ttm":7.46,"pe":39.32},{"date":"2026-05-15","close":300.23,"eps_ttm":7.46,"pe":40.25},{"date":"2026-05-22","close":308.82,"eps_ttm":7.46,"pe":41.4},{"date":"2026-05-29","close":312.06,"eps_ttm":7.46,"pe":41.83},{"date":"2026-06-05","close":307.34,"eps_ttm":7.46,"pe":41.2},{"date":"2026-06-12","close":291.13,"eps_ttm":7.46,"pe":39.03},{"date":"2026-06-18","close":298.01,"eps_ttm":7.46,"pe":39.95},{"date":"2026-06-26","close":283.78,"eps_ttm":7.46,"pe":38.04},{"date":"2026-07-02","close":308.63,"eps_ttm":7.46,"pe":41.37},{"date":"2026-07-10","close":315.32,"eps_ttm":7.46,"pe":42.27},{"date":"2026-07-17","close":333.74,"eps_ttm":7.46,"pe":44.74},{"date":"2026-07-24","close":333.02,"eps_ttm":7.46,"pe":44.64},{"date":"2026-07-31","close":308.91,"eps_ttm":7.46,"pe":41.41},{"date":"2026-08-07","close":313.33,"eps_ttm":7.46,"pe":42}]} | |
| \ No newline at end of file | ||
| 1 | +{"ticker":"AAPL","metric":"pe","note":"Illustrative fallback used only when /v1/fundamentals/AAPL/ratios/daily is not live: split-adjusted weekly close (HF Market Data, adj_split) divided by the diluted EPS reported in Apple's latest Form 10-K at that date (10-K EPS stepped at each release; not a trailing-twelve-month series).","source":{"prices":"https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day&adjustment=adj_split&start=2015-01-01&limit=50000","eps":"Apple Inc. Form 10-K filings FY2014\u2013FY2025 (diluted EPS, split-adjusted)"},"generated_at":"2026-09-04T23:38:04.919Z","points":[{"date":"2015-01-02","close":27.33,"eps_ttm":1.61,"pe":16.98},{"date":"2015-01-09","close":28.0,"eps_ttm":1.61,"pe":17.39},{"date":"2015-01-16","close":26.5,"eps_ttm":1.61,"pe":16.46},{"date":"2015-01-23","close":28.25,"eps_ttm":1.61,"pe":17.54},{"date":"2015-01-30","close":29.29,"eps_ttm":1.61,"pe":18.19},{"date":"2015-02-06","close":29.73,"eps_ttm":1.61,"pe":18.47},{"date":"2015-02-13","close":31.77,"eps_ttm":1.61,"pe":19.73},{"date":"2015-02-20","close":32.38,"eps_ttm":1.61,"pe":20.11},{"date":"2015-02-27","close":32.12,"eps_ttm":1.61,"pe":19.95},{"date":"2015-03-06","close":31.65,"eps_ttm":1.61,"pe":19.66},{"date":"2015-03-13","close":30.9,"eps_ttm":1.61,"pe":19.19},{"date":"2015-03-20","close":31.48,"eps_ttm":1.61,"pe":19.55},{"date":"2015-03-27","close":30.81,"eps_ttm":1.61,"pe":19.14},{"date":"2015-04-02","close":31.33,"eps_ttm":1.61,"pe":19.46},{"date":"2015-04-10","close":31.77,"eps_ttm":1.61,"pe":19.74},{"date":"2015-04-17","close":31.19,"eps_ttm":1.61,"pe":19.37},{"date":"2015-04-24","close":32.57,"eps_ttm":1.61,"pe":20.23},{"date":"2015-05-01","close":32.24,"eps_ttm":1.61,"pe":20.02},{"date":"2015-05-08","close":31.91,"eps_ttm":1.61,"pe":19.82},{"date":"2015-05-15","close":32.19,"eps_ttm":1.61,"pe":20},{"date":"2015-05-22","close":33.13,"eps_ttm":1.61,"pe":20.58},{"date":"2015-05-29","close":32.57,"eps_ttm":1.61,"pe":20.23},{"date":"2015-06-05","close":32.16,"eps_ttm":1.61,"pe":19.98},{"date":"2015-06-12","close":31.79,"eps_ttm":1.61,"pe":19.75},{"date":"2015-06-19","close":31.65,"eps_ttm":1.61,"pe":19.66},{"date":"2015-06-26","close":31.69,"eps_ttm":1.61,"pe":19.68},{"date":"2015-07-02","close":31.61,"eps_ttm":1.61,"pe":19.63},{"date":"2015-07-10","close":30.82,"eps_ttm":1.61,"pe":19.14},{"date":"2015-07-17","close":32.41,"eps_ttm":1.61,"pe":20.13},{"date":"2015-07-24","close":31.12,"eps_ttm":1.61,"pe":19.33},{"date":"2015-07-31","close":30.32,"eps_ttm":1.61,"pe":18.84},{"date":"2015-08-07","close":28.88,"eps_ttm":1.61,"pe":17.94},{"date":"2015-08-14","close":28.99,"eps_ttm":1.61,"pe":18.01},{"date":"2015-08-21","close":26.44,"eps_ttm":1.61,"pe":16.42},{"date":"2015-08-28","close":28.32,"eps_ttm":1.61,"pe":17.59},{"date":"2015-09-04","close":27.32,"eps_ttm":1.61,"pe":16.97},{"date":"2015-09-11","close":28.55,"eps_ttm":1.61,"pe":17.73},{"date":"2015-09-18","close":28.36,"eps_ttm":1.61,"pe":17.62},{"date":"2015-09-25","close":28.68,"eps_ttm":1.61,"pe":17.81},{"date":"2015-10-02","close":27.59,"eps_ttm":1.61,"pe":17.14},{"date":"2015-10-09","close":28.03,"eps_ttm":1.61,"pe":17.41},{"date":"2015-10-16","close":27.76,"eps_ttm":1.61,"pe":17.24},{"date":"2015-10-23","close":29.77,"eps_ttm":1.61,"pe":18.49},{"date":"2015-10-30","close":29.88,"eps_ttm":2.31,"pe":12.93},{"date":"2015-11-06","close":30.27,"eps_ttm":2.31,"pe":13.1},{"date":"2015-11-13","close":28.09,"eps_ttm":2.31,"pe":12.16},{"date":"2015-11-20","close":29.82,"eps_ttm":2.31,"pe":12.91},{"date":"2015-11-27","close":29.45,"eps_ttm":2.31,"pe":12.75},{"date":"2015-12-04","close":29.76,"eps_ttm":2.31,"pe":12.88},{"date":"2015-12-11","close":28.3,"eps_ttm":2.31,"pe":12.25},{"date":"2015-12-18","close":26.51,"eps_ttm":2.31,"pe":11.48},{"date":"2015-12-24","close":27.01,"eps_ttm":2.31,"pe":11.69},{"date":"2015-12-31","close":26.32,"eps_ttm":2.31,"pe":11.39},{"date":"2016-01-08","close":24.24,"eps_ttm":2.31,"pe":10.49},{"date":"2016-01-15","close":24.28,"eps_ttm":2.31,"pe":10.51},{"date":"2016-01-22","close":25.36,"eps_ttm":2.31,"pe":10.98},{"date":"2016-01-29","close":24.34,"eps_ttm":2.31,"pe":10.53},{"date":"2016-02-05","close":23.5,"eps_ttm":2.31,"pe":10.18},{"date":"2016-02-12","close":23.5,"eps_ttm":2.31,"pe":10.17},{"date":"2016-02-19","close":24.01,"eps_ttm":2.31,"pe":10.39},{"date":"2016-02-26","close":24.23,"eps_ttm":2.31,"pe":10.49},{"date":"2016-03-04","close":25.75,"eps_ttm":2.31,"pe":11.15},{"date":"2016-03-11","close":25.57,"eps_ttm":2.31,"pe":11.07},{"date":"2016-03-18","close":26.48,"eps_ttm":2.31,"pe":11.46},{"date":"2016-03-24","close":26.42,"eps_ttm":2.31,"pe":11.44},{"date":"2016-04-01","close":27.5,"eps_ttm":2.31,"pe":11.9},{"date":"2016-04-08","close":27.16,"eps_ttm":2.31,"pe":11.76},{"date":"2016-04-15","close":27.46,"eps_ttm":2.31,"pe":11.89},{"date":"2016-04-22","close":26.42,"eps_ttm":2.31,"pe":11.44},{"date":"2016-04-29","close":23.43,"eps_ttm":2.31,"pe":10.15},{"date":"2016-05-06","close":23.18,"eps_ttm":2.31,"pe":10.03},{"date":"2016-05-13","close":22.63,"eps_ttm":2.31,"pe":9.8},{"date":"2016-05-20","close":23.8,"eps_ttm":2.31,"pe":10.31},{"date":"2016-05-27","close":25.09,"eps_ttm":2.31,"pe":10.86},{"date":"2016-06-03","close":24.48,"eps_ttm":2.31,"pe":10.6},{"date":"2016-06-10","close":24.71,"eps_ttm":2.31,"pe":10.7},{"date":"2016-06-17","close":23.83,"eps_ttm":2.31,"pe":10.32},{"date":"2016-06-24","close":23.35,"eps_ttm":2.31,"pe":10.11},{"date":"2016-07-01","close":23.97,"eps_ttm":2.31,"pe":10.38},{"date":"2016-07-08","close":24.17,"eps_ttm":2.31,"pe":10.46},{"date":"2016-07-15","close":24.7,"eps_ttm":2.31,"pe":10.69},{"date":"2016-07-22","close":24.66,"eps_ttm":2.31,"pe":10.68},{"date":"2016-07-29","close":26.05,"eps_ttm":2.31,"pe":11.28},{"date":"2016-08-05","close":26.87,"eps_ttm":2.31,"pe":11.63},{"date":"2016-08-12","close":27.05,"eps_ttm":2.31,"pe":11.71},{"date":"2016-08-19","close":27.34,"eps_ttm":2.31,"pe":11.84},{"date":"2016-08-26","close":26.73,"eps_ttm":2.31,"pe":11.57},{"date":"2016-09-02","close":26.93,"eps_ttm":2.31,"pe":11.66},{"date":"2016-09-09","close":25.78,"eps_ttm":2.31,"pe":11.16},{"date":"2016-09-16","close":28.73,"eps_ttm":2.31,"pe":12.44},{"date":"2016-09-23","close":28.18,"eps_ttm":2.31,"pe":12.2},{"date":"2016-09-30","close":28.26,"eps_ttm":2.31,"pe":12.23},{"date":"2016-10-07","close":28.52,"eps_ttm":2.31,"pe":12.34},{"date":"2016-10-14","close":29.41,"eps_ttm":2.31,"pe":12.73},{"date":"2016-10-21","close":29.15,"eps_ttm":2.31,"pe":12.62},{"date":"2016-10-28","close":28.43,"eps_ttm":2.08,"pe":13.67},{"date":"2016-11-04","close":27.21,"eps_ttm":2.08,"pe":13.08},{"date":"2016-11-11","close":27.11,"eps_ttm":2.08,"pe":13.03},{"date":"2016-11-18","close":27.52,"eps_ttm":2.08,"pe":13.23},{"date":"2016-11-25","close":27.95,"eps_ttm":2.08,"pe":13.44},{"date":"2016-12-02","close":27.48,"eps_ttm":2.08,"pe":13.21},{"date":"2016-12-09","close":28.49,"eps_ttm":2.08,"pe":13.7},{"date":"2016-12-16","close":28.99,"eps_ttm":2.08,"pe":13.94},{"date":"2016-12-23","close":29.13,"eps_ttm":2.08,"pe":14},{"date":"2016-12-30","close":28.95,"eps_ttm":2.08,"pe":13.92},{"date":"2017-01-06","close":29.48,"eps_ttm":2.08,"pe":14.17},{"date":"2017-01-13","close":29.76,"eps_ttm":2.08,"pe":14.31},{"date":"2017-01-20","close":30,"eps_ttm":2.08,"pe":14.42},{"date":"2017-01-27","close":30.49,"eps_ttm":2.08,"pe":14.66},{"date":"2017-02-03","close":32.27,"eps_ttm":2.08,"pe":15.51},{"date":"2017-02-10","close":33.03,"eps_ttm":2.08,"pe":15.88},{"date":"2017-02-17","close":33.93,"eps_ttm":2.08,"pe":16.31},{"date":"2017-02-24","close":34.16,"eps_ttm":2.08,"pe":16.43},{"date":"2017-03-03","close":34.95,"eps_ttm":2.08,"pe":16.8},{"date":"2017-03-10","close":34.78,"eps_ttm":2.08,"pe":16.72},{"date":"2017-03-17","close":35.0,"eps_ttm":2.08,"pe":16.83},{"date":"2017-03-24","close":35.16,"eps_ttm":2.08,"pe":16.9},{"date":"2017-03-31","close":35.91,"eps_ttm":2.08,"pe":17.27},{"date":"2017-04-07","close":35.84,"eps_ttm":2.08,"pe":17.23},{"date":"2017-04-13","close":35.26,"eps_ttm":2.08,"pe":16.95},{"date":"2017-04-21","close":35.57,"eps_ttm":2.08,"pe":17.1},{"date":"2017-04-28","close":35.91,"eps_ttm":2.08,"pe":17.27},{"date":"2017-05-05","close":37.24,"eps_ttm":2.08,"pe":17.9},{"date":"2017-05-12","close":39.02,"eps_ttm":2.08,"pe":18.76},{"date":"2017-05-19","close":38.27,"eps_ttm":2.08,"pe":18.4},{"date":"2017-05-26","close":38.4,"eps_ttm":2.08,"pe":18.46},{"date":"2017-06-02","close":38.86,"eps_ttm":2.08,"pe":18.68},{"date":"2017-06-09","close":37.24,"eps_ttm":2.08,"pe":17.91},{"date":"2017-06-16","close":35.57,"eps_ttm":2.08,"pe":17.1},{"date":"2017-06-23","close":36.57,"eps_ttm":2.08,"pe":17.58},{"date":"2017-06-30","close":36.01,"eps_ttm":2.08,"pe":17.31},{"date":"2017-07-07","close":36.05,"eps_ttm":2.08,"pe":17.33},{"date":"2017-07-14","close":37.26,"eps_ttm":2.08,"pe":17.91},{"date":"2017-07-21","close":37.57,"eps_ttm":2.08,"pe":18.06},{"date":"2017-07-28","close":37.38,"eps_ttm":2.08,"pe":17.97},{"date":"2017-08-04","close":39.1,"eps_ttm":2.08,"pe":18.8},{"date":"2017-08-11","close":39.37,"eps_ttm":2.08,"pe":18.93},{"date":"2017-08-18","close":39.38,"eps_ttm":2.08,"pe":18.93},{"date":"2017-08-25","close":39.97,"eps_ttm":2.08,"pe":19.21},{"date":"2017-09-01","close":41.01,"eps_ttm":2.08,"pe":19.72},{"date":"2017-09-08","close":39.66,"eps_ttm":2.08,"pe":19.07},{"date":"2017-09-15","close":39.97,"eps_ttm":2.08,"pe":19.22},{"date":"2017-09-22","close":37.97,"eps_ttm":2.08,"pe":18.26},{"date":"2017-09-29","close":38.53,"eps_ttm":2.08,"pe":18.52},{"date":"2017-10-06","close":38.83,"eps_ttm":2.08,"pe":18.67},{"date":"2017-10-13","close":39.25,"eps_ttm":2.08,"pe":18.87},{"date":"2017-10-20","close":39.06,"eps_ttm":2.08,"pe":18.78},{"date":"2017-10-27","close":40.76,"eps_ttm":2.08,"pe":19.6},{"date":"2017-11-03","close":43.12,"eps_ttm":2.3,"pe":18.75},{"date":"2017-11-10","close":43.67,"eps_ttm":2.3,"pe":18.99},{"date":"2017-11-17","close":42.54,"eps_ttm":2.3,"pe":18.49},{"date":"2017-11-24","close":43.74,"eps_ttm":2.3,"pe":19.02},{"date":"2017-12-01","close":42.76,"eps_ttm":2.3,"pe":18.59},{"date":"2017-12-08","close":42.34,"eps_ttm":2.3,"pe":18.41},{"date":"2017-12-15","close":43.49,"eps_ttm":2.3,"pe":18.91},{"date":"2017-12-22","close":43.75,"eps_ttm":2.3,"pe":19.02},{"date":"2017-12-29","close":42.31,"eps_ttm":2.3,"pe":18.39},{"date":"2018-01-05","close":43.75,"eps_ttm":2.3,"pe":19.02},{"date":"2018-01-12","close":44.27,"eps_ttm":2.3,"pe":19.25},{"date":"2018-01-19","close":44.62,"eps_ttm":2.3,"pe":19.4},{"date":"2018-01-26","close":42.88,"eps_ttm":2.3,"pe":18.64},{"date":"2018-02-02","close":40.12,"eps_ttm":2.3,"pe":17.45},{"date":"2018-02-09","close":39.1,"eps_ttm":2.3,"pe":17},{"date":"2018-02-16","close":43.11,"eps_ttm":2.3,"pe":18.74},{"date":"2018-02-23","close":43.88,"eps_ttm":2.3,"pe":19.08},{"date":"2018-03-02","close":44.05,"eps_ttm":2.3,"pe":19.15},{"date":"2018-03-09","close":44.99,"eps_ttm":2.3,"pe":19.56},{"date":"2018-03-16","close":44.51,"eps_ttm":2.3,"pe":19.35},{"date":"2018-03-23","close":41.23,"eps_ttm":2.3,"pe":17.93},{"date":"2018-03-29","close":41.95,"eps_ttm":2.3,"pe":18.24},{"date":"2018-04-06","close":42.09,"eps_ttm":2.3,"pe":18.3},{"date":"2018-04-13","close":43.68,"eps_ttm":2.3,"pe":18.99},{"date":"2018-04-20","close":41.43,"eps_ttm":2.3,"pe":18.01},{"date":"2018-04-27","close":40.58,"eps_ttm":2.3,"pe":17.64},{"date":"2018-05-04","close":45.96,"eps_ttm":2.3,"pe":19.98},{"date":"2018-05-11","close":47.15,"eps_ttm":2.3,"pe":20.5},{"date":"2018-05-18","close":46.58,"eps_ttm":2.3,"pe":20.25},{"date":"2018-05-25","close":47.15,"eps_ttm":2.3,"pe":20.5},{"date":"2018-06-01","close":47.56,"eps_ttm":2.3,"pe":20.68},{"date":"2018-06-08","close":47.92,"eps_ttm":2.3,"pe":20.84},{"date":"2018-06-15","close":47.21,"eps_ttm":2.3,"pe":20.53},{"date":"2018-06-22","close":46.23,"eps_ttm":2.3,"pe":20.1},{"date":"2018-06-29","close":46.28,"eps_ttm":2.3,"pe":20.12},{"date":"2018-07-06","close":46.99,"eps_ttm":2.3,"pe":20.43},{"date":"2018-07-13","close":47.83,"eps_ttm":2.3,"pe":20.8},{"date":"2018-07-20","close":47.86,"eps_ttm":2.3,"pe":20.81},{"date":"2018-07-27","close":47.74,"eps_ttm":2.3,"pe":20.76},{"date":"2018-08-03","close":52.0,"eps_ttm":2.3,"pe":22.61},{"date":"2018-08-10","close":51.88,"eps_ttm":2.3,"pe":22.56},{"date":"2018-08-17","close":54.4,"eps_ttm":2.3,"pe":23.65},{"date":"2018-08-24","close":54.04,"eps_ttm":2.3,"pe":23.5},{"date":"2018-08-31","close":56.91,"eps_ttm":2.3,"pe":24.74},{"date":"2018-09-07","close":55.33,"eps_ttm":2.3,"pe":24.05},{"date":"2018-09-14","close":55.96,"eps_ttm":2.3,"pe":24.33},{"date":"2018-09-21","close":54.41,"eps_ttm":2.3,"pe":23.66},{"date":"2018-09-28","close":56.44,"eps_ttm":2.3,"pe":24.54},{"date":"2018-10-05","close":56.07,"eps_ttm":2.3,"pe":24.38},{"date":"2018-10-12","close":55.53,"eps_ttm":2.3,"pe":24.14},{"date":"2018-10-19","close":54.83,"eps_ttm":2.3,"pe":23.84},{"date":"2018-10-26","close":54.08,"eps_ttm":2.3,"pe":23.51},{"date":"2018-11-02","close":51.87,"eps_ttm":2.98,"pe":17.41},{"date":"2018-11-09","close":51.12,"eps_ttm":2.98,"pe":17.15},{"date":"2018-11-16","close":48.38,"eps_ttm":2.98,"pe":16.24},{"date":"2018-11-23","close":43.07,"eps_ttm":2.98,"pe":14.45},{"date":"2018-11-30","close":44.65,"eps_ttm":2.98,"pe":14.98},{"date":"2018-12-07","close":42.12,"eps_ttm":2.98,"pe":14.14},{"date":"2018-12-14","close":41.37,"eps_ttm":2.98,"pe":13.88},{"date":"2018-12-21","close":37.68,"eps_ttm":2.98,"pe":12.65},{"date":"2018-12-28","close":39.06,"eps_ttm":2.98,"pe":13.11},{"date":"2019-01-04","close":37.06,"eps_ttm":2.98,"pe":12.44},{"date":"2019-01-11","close":38.07,"eps_ttm":2.98,"pe":12.78},{"date":"2019-01-18","close":39.2,"eps_ttm":2.98,"pe":13.16},{"date":"2019-01-25","close":39.44,"eps_ttm":2.98,"pe":13.23},{"date":"2019-02-01","close":41.63,"eps_ttm":2.98,"pe":13.97},{"date":"2019-02-08","close":42.6,"eps_ttm":2.98,"pe":14.3},{"date":"2019-02-15","close":42.6,"eps_ttm":2.98,"pe":14.3},{"date":"2019-02-22","close":43.24,"eps_ttm":2.98,"pe":14.51},{"date":"2019-03-01","close":43.74,"eps_ttm":2.98,"pe":14.68},{"date":"2019-03-08","close":43.23,"eps_ttm":2.98,"pe":14.51},{"date":"2019-03-15","close":46.53,"eps_ttm":2.98,"pe":15.61},{"date":"2019-03-22","close":47.76,"eps_ttm":2.98,"pe":16.03},{"date":"2019-03-29","close":47.49,"eps_ttm":2.98,"pe":15.94},{"date":"2019-04-05","close":49.25,"eps_ttm":2.98,"pe":16.53},{"date":"2019-04-12","close":49.72,"eps_ttm":2.98,"pe":16.68},{"date":"2019-04-18","close":50.97,"eps_ttm":2.98,"pe":17.1},{"date":"2019-04-26","close":51.08,"eps_ttm":2.98,"pe":17.14},{"date":"2019-05-03","close":52.94,"eps_ttm":2.98,"pe":17.76},{"date":"2019-05-10","close":49.3,"eps_ttm":2.98,"pe":16.54},{"date":"2019-05-17","close":47.25,"eps_ttm":2.98,"pe":15.86},{"date":"2019-05-24","close":44.74,"eps_ttm":2.98,"pe":15.01},{"date":"2019-05-31","close":43.77,"eps_ttm":2.98,"pe":14.69},{"date":"2019-06-07","close":47.54,"eps_ttm":2.98,"pe":15.95},{"date":"2019-06-14","close":48.19,"eps_ttm":2.98,"pe":16.17},{"date":"2019-06-21","close":49.7,"eps_ttm":2.98,"pe":16.68},{"date":"2019-06-28","close":49.48,"eps_ttm":2.98,"pe":16.6},{"date":"2019-07-05","close":51.06,"eps_ttm":2.98,"pe":17.13},{"date":"2019-07-12","close":50.83,"eps_ttm":2.98,"pe":17.06},{"date":"2019-07-19","close":50.65,"eps_ttm":2.98,"pe":17},{"date":"2019-07-26","close":51.94,"eps_ttm":2.98,"pe":17.43},{"date":"2019-08-02","close":51.01,"eps_ttm":2.98,"pe":17.12},{"date":"2019-08-09","close":50.25,"eps_ttm":2.98,"pe":16.86},{"date":"2019-08-16","close":51.62,"eps_ttm":2.98,"pe":17.32},{"date":"2019-08-23","close":50.66,"eps_ttm":2.98,"pe":17},{"date":"2019-08-30","close":52.19,"eps_ttm":2.98,"pe":17.51},{"date":"2019-09-06","close":53.31,"eps_ttm":2.98,"pe":17.89},{"date":"2019-09-13","close":54.69,"eps_ttm":2.98,"pe":18.35},{"date":"2019-09-20","close":54.43,"eps_ttm":2.98,"pe":18.27},{"date":"2019-09-27","close":54.7,"eps_ttm":2.98,"pe":18.36},{"date":"2019-10-04","close":56.75,"eps_ttm":2.98,"pe":19.04},{"date":"2019-10-11","close":59.05,"eps_ttm":2.98,"pe":19.82},{"date":"2019-10-18","close":59.1,"eps_ttm":2.98,"pe":19.83},{"date":"2019-10-25","close":61.65,"eps_ttm":2.98,"pe":20.69},{"date":"2019-11-01","close":63.95,"eps_ttm":2.97,"pe":21.53},{"date":"2019-11-08","close":65.03,"eps_ttm":2.97,"pe":21.9},{"date":"2019-11-15","close":66.44,"eps_ttm":2.97,"pe":22.37},{"date":"2019-11-22","close":65.44,"eps_ttm":2.97,"pe":22.04},{"date":"2019-11-29","close":66.81,"eps_ttm":2.97,"pe":22.5},{"date":"2019-12-06","close":67.68,"eps_ttm":2.97,"pe":22.79},{"date":"2019-12-13","close":68.79,"eps_ttm":2.97,"pe":23.16},{"date":"2019-12-20","close":69.86,"eps_ttm":2.97,"pe":23.52},{"date":"2019-12-27","close":72.45,"eps_ttm":2.97,"pe":24.39},{"date":"2020-01-03","close":74.36,"eps_ttm":2.97,"pe":25.04},{"date":"2020-01-10","close":77.58,"eps_ttm":2.97,"pe":26.12},{"date":"2020-01-17","close":79.68,"eps_ttm":2.97,"pe":26.83},{"date":"2020-01-24","close":79.58,"eps_ttm":2.97,"pe":26.79},{"date":"2020-01-31","close":77.38,"eps_ttm":2.97,"pe":26.05},{"date":"2020-02-07","close":80.01,"eps_ttm":2.97,"pe":26.94},{"date":"2020-02-14","close":81.24,"eps_ttm":2.97,"pe":27.35},{"date":"2020-02-21","close":78.26,"eps_ttm":2.97,"pe":26.35},{"date":"2020-02-28","close":68.34,"eps_ttm":2.97,"pe":23.01},{"date":"2020-03-06","close":72.26,"eps_ttm":2.97,"pe":24.33},{"date":"2020-03-13","close":69.49,"eps_ttm":2.97,"pe":23.4},{"date":"2020-03-20","close":57.31,"eps_ttm":2.97,"pe":19.3},{"date":"2020-03-27","close":61.94,"eps_ttm":2.97,"pe":20.85},{"date":"2020-04-03","close":60.35,"eps_ttm":2.97,"pe":20.32},{"date":"2020-04-09","close":67.0,"eps_ttm":2.97,"pe":22.56},{"date":"2020-04-17","close":70.7,"eps_ttm":2.97,"pe":23.8},{"date":"2020-04-24","close":70.74,"eps_ttm":2.97,"pe":23.82},{"date":"2020-05-01","close":72.27,"eps_ttm":2.97,"pe":24.33},{"date":"2020-05-08","close":77.53,"eps_ttm":2.97,"pe":26.11},{"date":"2020-05-15","close":76.93,"eps_ttm":2.97,"pe":25.9},{"date":"2020-05-22","close":79.72,"eps_ttm":2.97,"pe":26.84},{"date":"2020-05-29","close":79.48,"eps_ttm":2.97,"pe":26.76},{"date":"2020-06-05","close":82.88,"eps_ttm":2.97,"pe":27.9},{"date":"2020-06-12","close":84.7,"eps_ttm":2.97,"pe":28.52},{"date":"2020-06-19","close":87.43,"eps_ttm":2.97,"pe":29.44},{"date":"2020-06-26","close":88.41,"eps_ttm":2.97,"pe":29.77},{"date":"2020-07-02","close":91.03,"eps_ttm":2.97,"pe":30.65},{"date":"2020-07-10","close":95.92,"eps_ttm":2.97,"pe":32.3},{"date":"2020-07-17","close":96.33,"eps_ttm":2.97,"pe":32.43},{"date":"2020-07-24","close":92.61,"eps_ttm":2.97,"pe":31.18},{"date":"2020-07-31","close":106.26,"eps_ttm":2.97,"pe":35.78},{"date":"2020-08-07","close":111.11,"eps_ttm":2.97,"pe":37.41},{"date":"2020-08-14","close":114.91,"eps_ttm":2.97,"pe":38.69},{"date":"2020-08-21","close":124.37,"eps_ttm":2.97,"pe":41.88},{"date":"2020-08-28","close":124.81,"eps_ttm":2.97,"pe":42.02},{"date":"2020-09-04","close":120.96,"eps_ttm":2.97,"pe":40.73},{"date":"2020-09-11","close":112,"eps_ttm":2.97,"pe":37.71},{"date":"2020-09-18","close":106.84,"eps_ttm":2.97,"pe":35.97},{"date":"2020-09-25","close":112.28,"eps_ttm":2.97,"pe":37.8},{"date":"2020-10-02","close":113.02,"eps_ttm":2.97,"pe":38.05},{"date":"2020-10-09","close":116.97,"eps_ttm":2.97,"pe":39.38},{"date":"2020-10-16","close":119.02,"eps_ttm":2.97,"pe":40.07},{"date":"2020-10-23","close":115.04,"eps_ttm":2.97,"pe":38.73},{"date":"2020-10-30","close":108.86,"eps_ttm":3.28,"pe":33.19},{"date":"2020-11-06","close":118.69,"eps_ttm":3.28,"pe":36.19},{"date":"2020-11-13","close":119.26,"eps_ttm":3.28,"pe":36.36},{"date":"2020-11-20","close":117.34,"eps_ttm":3.28,"pe":35.77},{"date":"2020-11-27","close":116.59,"eps_ttm":3.28,"pe":35.55},{"date":"2020-12-04","close":122.25,"eps_ttm":3.28,"pe":37.27},{"date":"2020-12-11","close":122.41,"eps_ttm":3.28,"pe":37.32},{"date":"2020-12-18","close":126.66,"eps_ttm":3.28,"pe":38.62},{"date":"2020-12-24","close":131.97,"eps_ttm":3.28,"pe":40.23},{"date":"2020-12-31","close":132.69,"eps_ttm":3.28,"pe":40.45},{"date":"2021-01-08","close":132.05,"eps_ttm":3.28,"pe":40.26},{"date":"2021-01-15","close":127.14,"eps_ttm":3.28,"pe":38.76},{"date":"2021-01-22","close":139.07,"eps_ttm":3.28,"pe":42.4},{"date":"2021-01-29","close":131.96,"eps_ttm":3.28,"pe":40.23},{"date":"2021-02-05","close":136.76,"eps_ttm":3.28,"pe":41.7},{"date":"2021-02-12","close":135.37,"eps_ttm":3.28,"pe":41.27},{"date":"2021-02-19","close":129.87,"eps_ttm":3.28,"pe":39.59},{"date":"2021-02-26","close":121.26,"eps_ttm":3.28,"pe":36.97},{"date":"2021-03-05","close":121.42,"eps_ttm":3.28,"pe":37.02},{"date":"2021-03-12","close":121.03,"eps_ttm":3.28,"pe":36.9},{"date":"2021-03-19","close":119.99,"eps_ttm":3.28,"pe":36.58},{"date":"2021-03-26","close":121.21,"eps_ttm":3.28,"pe":36.95},{"date":"2021-04-01","close":123,"eps_ttm":3.28,"pe":37.5},{"date":"2021-04-09","close":133,"eps_ttm":3.28,"pe":40.55},{"date":"2021-04-16","close":134.16,"eps_ttm":3.28,"pe":40.9},{"date":"2021-04-23","close":134.32,"eps_ttm":3.28,"pe":40.95},{"date":"2021-04-30","close":131.46,"eps_ttm":3.28,"pe":40.08},{"date":"2021-05-07","close":130.21,"eps_ttm":3.28,"pe":39.7},{"date":"2021-05-14","close":127.45,"eps_ttm":3.28,"pe":38.86},{"date":"2021-05-21","close":125.43,"eps_ttm":3.28,"pe":38.24},{"date":"2021-05-28","close":124.61,"eps_ttm":3.28,"pe":37.99},{"date":"2021-06-04","close":125.89,"eps_ttm":3.28,"pe":38.38},{"date":"2021-06-11","close":127.35,"eps_ttm":3.28,"pe":38.83},{"date":"2021-06-18","close":130.46,"eps_ttm":3.28,"pe":39.77},{"date":"2021-06-25","close":133.11,"eps_ttm":3.28,"pe":40.58},{"date":"2021-07-02","close":139.96,"eps_ttm":3.28,"pe":42.67},{"date":"2021-07-09","close":145.11,"eps_ttm":3.28,"pe":44.24},{"date":"2021-07-16","close":146.39,"eps_ttm":3.28,"pe":44.63},{"date":"2021-07-23","close":148.56,"eps_ttm":3.28,"pe":45.29},{"date":"2021-07-30","close":145.86,"eps_ttm":3.28,"pe":44.47},{"date":"2021-08-06","close":146.14,"eps_ttm":3.28,"pe":44.55},{"date":"2021-08-13","close":149.1,"eps_ttm":3.28,"pe":45.46},{"date":"2021-08-20","close":148.19,"eps_ttm":3.28,"pe":45.18},{"date":"2021-08-27","close":148.6,"eps_ttm":3.28,"pe":45.3},{"date":"2021-09-03","close":154.3,"eps_ttm":3.28,"pe":47.04},{"date":"2021-09-10","close":148.97,"eps_ttm":3.28,"pe":45.42},{"date":"2021-09-17","close":146.06,"eps_ttm":3.28,"pe":44.53},{"date":"2021-09-24","close":146.92,"eps_ttm":3.28,"pe":44.79},{"date":"2021-10-01","close":142.65,"eps_ttm":3.28,"pe":43.49},{"date":"2021-10-08","close":142.9,"eps_ttm":3.28,"pe":43.57},{"date":"2021-10-15","close":144.84,"eps_ttm":3.28,"pe":44.16},{"date":"2021-10-22","close":148.69,"eps_ttm":3.28,"pe":45.33},{"date":"2021-10-29","close":149.8,"eps_ttm":5.61,"pe":26.7},{"date":"2021-11-05","close":151.28,"eps_ttm":5.61,"pe":26.97},{"date":"2021-11-12","close":149.99,"eps_ttm":5.61,"pe":26.74},{"date":"2021-11-19","close":160.55,"eps_ttm":5.61,"pe":28.62},{"date":"2021-11-26","close":156.81,"eps_ttm":5.61,"pe":27.95},{"date":"2021-12-03","close":161.84,"eps_ttm":5.61,"pe":28.85},{"date":"2021-12-10","close":179.45,"eps_ttm":5.61,"pe":31.99},{"date":"2021-12-17","close":171.14,"eps_ttm":5.61,"pe":30.51},{"date":"2021-12-23","close":176.28,"eps_ttm":5.61,"pe":31.42},{"date":"2021-12-31","close":177.57,"eps_ttm":5.61,"pe":31.65},{"date":"2022-01-07","close":172.17,"eps_ttm":5.61,"pe":30.69},{"date":"2022-01-14","close":173.07,"eps_ttm":5.61,"pe":30.85},{"date":"2022-01-21","close":162.41,"eps_ttm":5.61,"pe":28.95},{"date":"2022-01-28","close":170.33,"eps_ttm":5.61,"pe":30.36},{"date":"2022-02-04","close":172.39,"eps_ttm":5.61,"pe":30.73},{"date":"2022-02-11","close":168.64,"eps_ttm":5.61,"pe":30.06},{"date":"2022-02-18","close":167.3,"eps_ttm":5.61,"pe":29.82},{"date":"2022-02-25","close":164.85,"eps_ttm":5.61,"pe":29.39},{"date":"2022-03-04","close":163.17,"eps_ttm":5.61,"pe":29.09},{"date":"2022-03-11","close":154.73,"eps_ttm":5.61,"pe":27.58},{"date":"2022-03-18","close":163.98,"eps_ttm":5.61,"pe":29.23},{"date":"2022-03-25","close":174.72,"eps_ttm":5.61,"pe":31.14},{"date":"2022-04-01","close":174.31,"eps_ttm":5.61,"pe":31.07},{"date":"2022-04-08","close":170.09,"eps_ttm":5.61,"pe":30.32},{"date":"2022-04-14","close":165.29,"eps_ttm":5.61,"pe":29.46},{"date":"2022-04-22","close":161.79,"eps_ttm":5.61,"pe":28.84},{"date":"2022-04-29","close":157.65,"eps_ttm":5.61,"pe":28.1},{"date":"2022-05-06","close":157.28,"eps_ttm":5.61,"pe":28.04},{"date":"2022-05-13","close":147.11,"eps_ttm":5.61,"pe":26.22},{"date":"2022-05-20","close":137.59,"eps_ttm":5.61,"pe":24.53},{"date":"2022-05-27","close":149.64,"eps_ttm":5.61,"pe":26.67},{"date":"2022-06-03","close":145.38,"eps_ttm":5.61,"pe":25.91},{"date":"2022-06-10","close":137.13,"eps_ttm":5.61,"pe":24.44},{"date":"2022-06-17","close":131.56,"eps_ttm":5.61,"pe":23.45},{"date":"2022-06-24","close":141.66,"eps_ttm":5.61,"pe":25.25},{"date":"2022-07-01","close":138.93,"eps_ttm":5.61,"pe":24.76},{"date":"2022-07-08","close":147.04,"eps_ttm":5.61,"pe":26.21},{"date":"2022-07-15","close":150.17,"eps_ttm":5.61,"pe":26.77},{"date":"2022-07-22","close":154.09,"eps_ttm":5.61,"pe":27.47},{"date":"2022-07-29","close":162.51,"eps_ttm":5.61,"pe":28.97},{"date":"2022-08-05","close":165.35,"eps_ttm":5.61,"pe":29.47},{"date":"2022-08-12","close":172.1,"eps_ttm":5.61,"pe":30.68},{"date":"2022-08-19","close":171.52,"eps_ttm":5.61,"pe":30.57},{"date":"2022-08-26","close":163.62,"eps_ttm":5.61,"pe":29.17},{"date":"2022-09-02","close":155.81,"eps_ttm":5.61,"pe":27.77},{"date":"2022-09-09","close":157.37,"eps_ttm":5.61,"pe":28.05},{"date":"2022-09-16","close":150.7,"eps_ttm":5.61,"pe":26.86},{"date":"2022-09-23","close":150.43,"eps_ttm":5.61,"pe":26.81},{"date":"2022-09-30","close":138.2,"eps_ttm":5.61,"pe":24.63},{"date":"2022-10-07","close":140.09,"eps_ttm":5.61,"pe":24.97},{"date":"2022-10-14","close":138.38,"eps_ttm":5.61,"pe":24.67},{"date":"2022-10-21","close":147.27,"eps_ttm":5.61,"pe":26.25},{"date":"2022-10-28","close":155.74,"eps_ttm":6.11,"pe":25.49},{"date":"2022-11-04","close":138.38,"eps_ttm":6.11,"pe":22.65},{"date":"2022-11-11","close":149.7,"eps_ttm":6.11,"pe":24.5},{"date":"2022-11-18","close":151.29,"eps_ttm":6.11,"pe":24.76},{"date":"2022-11-25","close":148.11,"eps_ttm":6.11,"pe":24.24},{"date":"2022-12-02","close":147.81,"eps_ttm":6.11,"pe":24.19},{"date":"2022-12-09","close":142.16,"eps_ttm":6.11,"pe":23.27},{"date":"2022-12-16","close":134.51,"eps_ttm":6.11,"pe":22.01},{"date":"2022-12-23","close":131.86,"eps_ttm":6.11,"pe":21.58},{"date":"2022-12-30","close":129.93,"eps_ttm":6.11,"pe":21.27},{"date":"2023-01-06","close":129.62,"eps_ttm":6.11,"pe":21.21},{"date":"2023-01-13","close":134.76,"eps_ttm":6.11,"pe":22.06},{"date":"2023-01-20","close":137.87,"eps_ttm":6.11,"pe":22.56},{"date":"2023-01-27","close":145.93,"eps_ttm":6.11,"pe":23.88},{"date":"2023-02-03","close":154.5,"eps_ttm":6.11,"pe":25.29},{"date":"2023-02-10","close":151.01,"eps_ttm":6.11,"pe":24.72},{"date":"2023-02-17","close":152.55,"eps_ttm":6.11,"pe":24.97},{"date":"2023-02-24","close":146.71,"eps_ttm":6.11,"pe":24.01},{"date":"2023-03-03","close":151.03,"eps_ttm":6.11,"pe":24.72},{"date":"2023-03-10","close":148.5,"eps_ttm":6.11,"pe":24.3},{"date":"2023-03-17","close":155,"eps_ttm":6.11,"pe":25.37},{"date":"2023-03-24","close":160.25,"eps_ttm":6.11,"pe":26.23},{"date":"2023-03-31","close":164.9,"eps_ttm":6.11,"pe":26.99},{"date":"2023-04-06","close":164.66,"eps_ttm":6.11,"pe":26.95},{"date":"2023-04-14","close":165.21,"eps_ttm":6.11,"pe":27.04},{"date":"2023-04-21","close":165.02,"eps_ttm":6.11,"pe":27.01},{"date":"2023-04-28","close":169.68,"eps_ttm":6.11,"pe":27.77},{"date":"2023-05-05","close":173.57,"eps_ttm":6.11,"pe":28.41},{"date":"2023-05-12","close":172.57,"eps_ttm":6.11,"pe":28.24},{"date":"2023-05-19","close":175.16,"eps_ttm":6.11,"pe":28.67},{"date":"2023-05-26","close":175.43,"eps_ttm":6.11,"pe":28.71},{"date":"2023-06-02","close":180.95,"eps_ttm":6.11,"pe":29.62},{"date":"2023-06-09","close":180.96,"eps_ttm":6.11,"pe":29.62},{"date":"2023-06-16","close":184.92,"eps_ttm":6.11,"pe":30.27},{"date":"2023-06-23","close":186.68,"eps_ttm":6.11,"pe":30.55},{"date":"2023-06-30","close":193.97,"eps_ttm":6.11,"pe":31.75},{"date":"2023-07-07","close":190.68,"eps_ttm":6.11,"pe":31.21},{"date":"2023-07-14","close":190.69,"eps_ttm":6.11,"pe":31.21},{"date":"2023-07-21","close":191.94,"eps_ttm":6.11,"pe":31.41},{"date":"2023-07-28","close":195.83,"eps_ttm":6.11,"pe":32.05},{"date":"2023-08-04","close":181.99,"eps_ttm":6.11,"pe":29.79},{"date":"2023-08-11","close":177.79,"eps_ttm":6.11,"pe":29.1},{"date":"2023-08-18","close":174.49,"eps_ttm":6.11,"pe":28.56},{"date":"2023-08-25","close":178.61,"eps_ttm":6.11,"pe":29.23},{"date":"2023-09-01","close":189.46,"eps_ttm":6.11,"pe":31.01},{"date":"2023-09-08","close":178.18,"eps_ttm":6.11,"pe":29.16},{"date":"2023-09-15","close":175.01,"eps_ttm":6.11,"pe":28.64},{"date":"2023-09-22","close":174.79,"eps_ttm":6.11,"pe":28.61},{"date":"2023-09-29","close":171.21,"eps_ttm":6.11,"pe":28.02},{"date":"2023-10-06","close":177.49,"eps_ttm":6.11,"pe":29.05},{"date":"2023-10-13","close":178.85,"eps_ttm":6.11,"pe":29.27},{"date":"2023-10-20","close":172.88,"eps_ttm":6.11,"pe":28.29},{"date":"2023-10-27","close":168.22,"eps_ttm":6.11,"pe":27.53},{"date":"2023-11-03","close":176.65,"eps_ttm":6.13,"pe":28.82},{"date":"2023-11-10","close":186.4,"eps_ttm":6.13,"pe":30.41},{"date":"2023-11-17","close":189.69,"eps_ttm":6.13,"pe":30.94},{"date":"2023-11-24","close":189.97,"eps_ttm":6.13,"pe":30.99},{"date":"2023-12-01","close":191.24,"eps_ttm":6.13,"pe":31.2},{"date":"2023-12-08","close":195.71,"eps_ttm":6.13,"pe":31.93},{"date":"2023-12-15","close":197.57,"eps_ttm":6.13,"pe":32.23},{"date":"2023-12-22","close":193.6,"eps_ttm":6.13,"pe":31.58},{"date":"2023-12-29","close":192.53,"eps_ttm":6.13,"pe":31.41},{"date":"2024-01-05","close":181.18,"eps_ttm":6.13,"pe":29.56},{"date":"2024-01-12","close":185.92,"eps_ttm":6.13,"pe":30.33},{"date":"2024-01-19","close":191.56,"eps_ttm":6.13,"pe":31.25},{"date":"2024-01-26","close":192.42,"eps_ttm":6.13,"pe":31.39},{"date":"2024-02-02","close":185.85,"eps_ttm":6.13,"pe":30.32},{"date":"2024-02-09","close":188.85,"eps_ttm":6.13,"pe":30.81},{"date":"2024-02-16","close":182.31,"eps_ttm":6.13,"pe":29.74},{"date":"2024-02-23","close":182.52,"eps_ttm":6.13,"pe":29.77},{"date":"2024-03-01","close":179.66,"eps_ttm":6.13,"pe":29.31},{"date":"2024-03-08","close":170.73,"eps_ttm":6.13,"pe":27.85},{"date":"2024-03-15","close":172.62,"eps_ttm":6.13,"pe":28.16},{"date":"2024-03-22","close":172.28,"eps_ttm":6.13,"pe":28.1},{"date":"2024-03-28","close":171.48,"eps_ttm":6.13,"pe":27.97},{"date":"2024-04-05","close":169.58,"eps_ttm":6.13,"pe":27.66},{"date":"2024-04-12","close":176.55,"eps_ttm":6.13,"pe":28.8},{"date":"2024-04-19","close":165,"eps_ttm":6.13,"pe":26.92},{"date":"2024-04-26","close":169.3,"eps_ttm":6.13,"pe":27.62},{"date":"2024-05-03","close":183.38,"eps_ttm":6.13,"pe":29.92},{"date":"2024-05-10","close":183.05,"eps_ttm":6.13,"pe":29.86},{"date":"2024-05-17","close":189.87,"eps_ttm":6.13,"pe":30.97},{"date":"2024-05-24","close":189.98,"eps_ttm":6.13,"pe":30.99},{"date":"2024-05-31","close":192.25,"eps_ttm":6.13,"pe":31.36},{"date":"2024-06-07","close":196.89,"eps_ttm":6.13,"pe":32.12},{"date":"2024-06-14","close":212.49,"eps_ttm":6.13,"pe":34.66},{"date":"2024-06-21","close":207.49,"eps_ttm":6.13,"pe":33.85},{"date":"2024-06-28","close":210.62,"eps_ttm":6.13,"pe":34.36},{"date":"2024-07-05","close":226.34,"eps_ttm":6.13,"pe":36.92},{"date":"2024-07-12","close":230.54,"eps_ttm":6.13,"pe":37.61},{"date":"2024-07-19","close":224.31,"eps_ttm":6.13,"pe":36.59},{"date":"2024-07-26","close":217.96,"eps_ttm":6.13,"pe":35.56},{"date":"2024-08-02","close":219.86,"eps_ttm":6.13,"pe":35.87},{"date":"2024-08-09","close":216.24,"eps_ttm":6.13,"pe":35.28},{"date":"2024-08-16","close":226.05,"eps_ttm":6.13,"pe":36.88},{"date":"2024-08-23","close":226.84,"eps_ttm":6.13,"pe":37},{"date":"2024-08-30","close":229,"eps_ttm":6.13,"pe":37.36},{"date":"2024-09-06","close":220.82,"eps_ttm":6.13,"pe":36.02},{"date":"2024-09-13","close":222.5,"eps_ttm":6.13,"pe":36.3},{"date":"2024-09-20","close":228.2,"eps_ttm":6.13,"pe":37.23},{"date":"2024-09-27","close":227.79,"eps_ttm":6.13,"pe":37.16},{"date":"2024-10-04","close":226.8,"eps_ttm":6.13,"pe":37},{"date":"2024-10-11","close":227.55,"eps_ttm":6.13,"pe":37.12},{"date":"2024-10-18","close":235,"eps_ttm":6.13,"pe":38.34},{"date":"2024-10-25","close":231.41,"eps_ttm":6.13,"pe":37.75},{"date":"2024-11-01","close":222.91,"eps_ttm":6.08,"pe":36.66},{"date":"2024-11-08","close":226.96,"eps_ttm":6.08,"pe":37.33},{"date":"2024-11-15","close":225,"eps_ttm":6.08,"pe":37.01},{"date":"2024-11-22","close":229.87,"eps_ttm":6.08,"pe":37.81},{"date":"2024-11-29","close":237.33,"eps_ttm":6.08,"pe":39.03},{"date":"2024-12-06","close":242.84,"eps_ttm":6.08,"pe":39.94},{"date":"2024-12-13","close":248.13,"eps_ttm":6.08,"pe":40.81},{"date":"2024-12-20","close":254.49,"eps_ttm":6.08,"pe":41.86},{"date":"2024-12-27","close":255.59,"eps_ttm":6.08,"pe":42.04},{"date":"2025-01-03","close":243.36,"eps_ttm":6.08,"pe":40.03},{"date":"2025-01-10","close":236.85,"eps_ttm":6.08,"pe":38.96},{"date":"2025-01-17","close":229.98,"eps_ttm":6.08,"pe":37.83},{"date":"2025-01-24","close":222.78,"eps_ttm":6.08,"pe":36.64},{"date":"2025-01-31","close":236,"eps_ttm":6.08,"pe":38.82},{"date":"2025-02-07","close":227.63,"eps_ttm":6.08,"pe":37.44},{"date":"2025-02-14","close":244.6,"eps_ttm":6.08,"pe":40.23},{"date":"2025-02-21","close":245.55,"eps_ttm":6.08,"pe":40.39},{"date":"2025-02-28","close":241.84,"eps_ttm":6.08,"pe":39.78},{"date":"2025-03-07","close":239.07,"eps_ttm":6.08,"pe":39.32},{"date":"2025-03-14","close":213.49,"eps_ttm":6.08,"pe":35.11},{"date":"2025-03-21","close":218.27,"eps_ttm":6.08,"pe":35.9},{"date":"2025-03-28","close":217.9,"eps_ttm":6.08,"pe":35.84},{"date":"2025-04-04","close":188.38,"eps_ttm":6.08,"pe":30.98},{"date":"2025-04-11","close":198.15,"eps_ttm":6.08,"pe":32.59},{"date":"2025-04-17","close":196.98,"eps_ttm":6.08,"pe":32.4},{"date":"2025-04-25","close":209.28,"eps_ttm":6.08,"pe":34.42},{"date":"2025-05-02","close":205.35,"eps_ttm":6.08,"pe":33.77},{"date":"2025-05-09","close":198.53,"eps_ttm":6.08,"pe":32.65},{"date":"2025-05-16","close":211.26,"eps_ttm":6.08,"pe":34.75},{"date":"2025-05-23","close":195.27,"eps_ttm":6.08,"pe":32.12},{"date":"2025-05-30","close":200.85,"eps_ttm":6.08,"pe":33.03},{"date":"2025-06-06","close":203.92,"eps_ttm":6.08,"pe":33.54},{"date":"2025-06-13","close":196.45,"eps_ttm":6.08,"pe":32.31},{"date":"2025-06-20","close":201,"eps_ttm":6.08,"pe":33.06},{"date":"2025-06-27","close":201.08,"eps_ttm":6.08,"pe":33.07},{"date":"2025-07-03","close":213.55,"eps_ttm":6.08,"pe":35.12},{"date":"2025-07-11","close":211.16,"eps_ttm":6.08,"pe":34.73},{"date":"2025-07-18","close":211.18,"eps_ttm":6.08,"pe":34.73},{"date":"2025-07-25","close":213.88,"eps_ttm":6.08,"pe":35.18},{"date":"2025-08-01","close":202.38,"eps_ttm":6.08,"pe":33.29},{"date":"2025-08-08","close":229.35,"eps_ttm":6.08,"pe":37.72},{"date":"2025-08-15","close":231.59,"eps_ttm":6.08,"pe":38.09},{"date":"2025-08-22","close":227.76,"eps_ttm":6.08,"pe":37.46},{"date":"2025-08-29","close":232.14,"eps_ttm":6.08,"pe":38.18},{"date":"2025-09-05","close":239.69,"eps_ttm":6.08,"pe":39.42},{"date":"2025-09-12","close":234.07,"eps_ttm":6.08,"pe":38.5},{"date":"2025-09-19","close":245.5,"eps_ttm":6.08,"pe":40.38},{"date":"2025-09-26","close":255.46,"eps_ttm":6.08,"pe":42.02},{"date":"2025-10-03","close":258.02,"eps_ttm":6.08,"pe":42.44},{"date":"2025-10-10","close":245.27,"eps_ttm":6.08,"pe":40.34},{"date":"2025-10-17","close":252.29,"eps_ttm":6.08,"pe":41.5},{"date":"2025-10-24","close":262.82,"eps_ttm":6.08,"pe":43.23},{"date":"2025-10-31","close":270.37,"eps_ttm":7.46,"pe":36.24},{"date":"2025-11-07","close":268.47,"eps_ttm":7.46,"pe":35.99},{"date":"2025-11-14","close":272.41,"eps_ttm":7.46,"pe":36.52},{"date":"2025-11-21","close":271.49,"eps_ttm":7.46,"pe":36.39},{"date":"2025-11-28","close":278.85,"eps_ttm":7.46,"pe":37.38},{"date":"2025-12-05","close":278.78,"eps_ttm":7.46,"pe":37.37},{"date":"2025-12-12","close":278.28,"eps_ttm":7.46,"pe":37.3},{"date":"2025-12-19","close":273.67,"eps_ttm":7.46,"pe":36.68},{"date":"2025-12-26","close":273.4,"eps_ttm":7.46,"pe":36.65},{"date":"2026-01-02","close":271.01,"eps_ttm":7.46,"pe":36.33},{"date":"2026-01-09","close":259.37,"eps_ttm":7.46,"pe":34.77},{"date":"2026-01-16","close":255.53,"eps_ttm":7.46,"pe":34.25},{"date":"2026-01-23","close":248.04,"eps_ttm":7.46,"pe":33.25},{"date":"2026-01-30","close":259.48,"eps_ttm":7.46,"pe":34.78},{"date":"2026-02-06","close":278.12,"eps_ttm":7.46,"pe":37.28},{"date":"2026-02-13","close":255.78,"eps_ttm":7.46,"pe":34.29},{"date":"2026-02-20","close":264.58,"eps_ttm":7.46,"pe":35.47},{"date":"2026-02-27","close":264.18,"eps_ttm":7.46,"pe":35.41},{"date":"2026-03-06","close":257.46,"eps_ttm":7.46,"pe":34.51},{"date":"2026-03-13","close":250.12,"eps_ttm":7.46,"pe":33.53},{"date":"2026-03-20","close":247.99,"eps_ttm":7.46,"pe":33.24},{"date":"2026-03-27","close":248.8,"eps_ttm":7.46,"pe":33.35},{"date":"2026-04-02","close":255.92,"eps_ttm":7.46,"pe":34.31},{"date":"2026-04-10","close":260.48,"eps_ttm":7.46,"pe":34.92},{"date":"2026-04-17","close":270.23,"eps_ttm":7.46,"pe":36.22},{"date":"2026-04-24","close":271.06,"eps_ttm":7.46,"pe":36.34},{"date":"2026-05-01","close":280.14,"eps_ttm":7.46,"pe":37.55},{"date":"2026-05-08","close":293.32,"eps_ttm":7.46,"pe":39.32},{"date":"2026-05-15","close":300.23,"eps_ttm":7.46,"pe":40.25},{"date":"2026-05-22","close":308.82,"eps_ttm":7.46,"pe":41.4},{"date":"2026-05-29","close":312.06,"eps_ttm":7.46,"pe":41.83},{"date":"2026-06-05","close":307.34,"eps_ttm":7.46,"pe":41.2},{"date":"2026-06-12","close":291.13,"eps_ttm":7.46,"pe":39.03},{"date":"2026-06-18","close":298.01,"eps_ttm":7.46,"pe":39.95},{"date":"2026-06-26","close":283.78,"eps_ttm":7.46,"pe":38.04},{"date":"2026-07-02","close":308.63,"eps_ttm":7.46,"pe":41.37},{"date":"2026-07-10","close":315.32,"eps_ttm":7.46,"pe":42.27},{"date":"2026-07-17","close":333.74,"eps_ttm":7.46,"pe":44.74},{"date":"2026-07-24","close":333.02,"eps_ttm":7.46,"pe":44.64},{"date":"2026-07-31","close":308.91,"eps_ttm":7.46,"pe":41.41},{"date":"2026-08-07","close":313.33,"eps_ttm":7.46,"pe":42}]} | |
| \ No newline at end of file | ||
modified
hfmarketdata/web/e2e/docs.spec.js
+48 −13
@@ -1,8 +1,10 @@ | ||
| 1 | −// Docs: navigation, generated reference, Try-it contract, search dialog, errors anchors, language persistence. | |
| 1 | +// Docs: navigation, generated reference, Try-it contract, search dialog, errors anchors, language persistence, | |
| 2 | +// no automatic live calls (Run live is explicit), wrap toggle, heading anchors without "#" in textContent. | |
| 2 | 3 | import { expect, test } from '@playwright/test' |
| 4 | +import { json, mockAnon } from './mocks.js' | |
| 3 | 5 | |
| 4 | 6 | test.describe('docs', () => { |
| 5 | − test('overview renders nav and quickstart guide loads with code panel', async ({ page }) => { | |
| 7 | + test('overview renders nav and quickstart guide loads with code panel (outline first)', async ({ page }) => { | |
| 6 | 8 | await page.goto('/docs') |
| 7 | 9 | await expect(page.getByTestId('docs-home')).toBeVisible() |
| 8 | 10 | await expect(page.getByRole('navigation', { name: 'Documentation' })).toBeVisible() |
@@ -10,10 +12,12 @@ test.describe('docs', () => { | ||
| 10 | 12 | await expect(page).toHaveURL(/\/docs\/quickstart$/) |
| 11 | 13 | await expect(page.getByRole('heading', { level: 1, name: 'Quickstart' })).toBeVisible() |
| 12 | 14 | await expect(page.getByTestId('guide-page')).toBeVisible() |
| 13 | − // right panel: code tabs with the 4 languages | |
| 14 | 15 | const panel = page.getByRole('complementary', { name: 'Code and outline' }) |
| 15 | 16 | await expect(panel.getByRole('radio', { name: 'Python' }).first()).toBeVisible() |
| 16 | 17 | await expect(panel.getByRole('radio', { name: 'R', exact: true }).first()).toBeVisible() |
| 18 | + // "On this page" comes before the code blocks | |
| 19 | + const [toc, code] = await Promise.all([panel.getByRole('navigation', { name: 'On this page' }).boundingBox(), panel.locator('.code').first().boundingBox()]) | |
| 20 | + expect(toc.y).toBeLessThan(code.y) | |
| 17 | 21 | await expect(page).toHaveTitle(/Quickstart/) |
| 18 | 22 | }) |
| 19 | 23 | |
@@ -37,20 +41,29 @@ test.describe('docs', () => { | ||
| 37 | 41 | await expect(page).toHaveURL(/\/docs\/reference\/bars_v1_bars__asset___ticker__get$/) |
| 38 | 42 | }) |
| 39 | 43 | |
| 40 | − test('operation page: parameters, samples, response example and Try-it deep link', async ({ page }) => { | |
| 44 | + test('operation page: parameters, samples, captured example, Try-it deep link, no automatic API call', async ({ page }) => { | |
| 45 | + const calls = [] | |
| 46 | + await mockAnon(page, url => { calls.push(url.pathname); return null }) | |
| 41 | 47 | await page.goto('/docs/reference/bars_v1_bars__asset___ticker__get') |
| 42 | 48 | await expect(page.getByTestId('operation-page')).toBeVisible() |
| 43 | 49 | await expect(page.getByRole('heading', { level: 1 })).toHaveText(/Bars/) |
| 44 | 50 | await expect(page.locator('.op-path')).toContainText('/v1/bars/{asset}/{ticker}') |
| 45 | − // parameters table lists the documented params | |
| 46 | 51 | for (const p of ['asset', 'ticker', 'timeframe', 'limit', 'format']) await expect(page.locator(`#param-${p}`)).toBeVisible() |
| 47 | − // curl sample uses the production URL with realistic values, no key | |
| 48 | 52 | const panel = page.getByRole('complementary', { name: 'Code and outline' }) |
| 49 | 53 | await panel.getByRole('radio', { name: 'curl' }).first().click() |
| 50 | 54 | await expect(panel.locator('.code').first()).toContainText('https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day') |
| 51 | 55 | await expect(panel.locator('.code').first()).not.toContainText('hfmd_live_') |
| 52 | − // captured response example present (content/examples) | |
| 53 | − await expect(panel.locator('.code').nth(1)).toContainText('"ticker": "AAPL"') | |
| 56 | + // captured response example present (content/examples), nothing fetched | |
| 57 | + await expect(page.getByTestId('response-example')).toContainText('"ticker": "AAPL"') | |
| 58 | + await expect(page.getByTestId('response-example')).toContainText('captured') | |
| 59 | + await page.waitForTimeout(400) | |
| 60 | + expect(calls.filter(p => p !== '/v1/me')).toEqual([]) | |
| 61 | + // Run live is explicit | |
| 62 | + await page.unroute('**/v1/**') | |
| 63 | + await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL' ? json({ count: 1, data: [{ ticker: 'AAPL', datetime: '2024-06-03', close: 1 }] }) : null)) | |
| 64 | + await page.getByTestId('run-live').click() | |
| 65 | + await expect(page.getByTestId('response-example')).toContainText('live') | |
| 66 | + await expect(page.getByTestId('response-example')).toContainText('keyless requests left') | |
| 54 | 67 | // Try it → /playground?ep=<operationId>&<params> |
| 55 | 68 | const href = await page.getByTestId('try-it').getAttribute('href') |
| 56 | 69 | expect(href).toMatch(/^\/playground\?ep=bars_v1_bars__asset___ticker__get&/) |
@@ -58,11 +71,34 @@ test.describe('docs', () => { | ||
| 58 | 71 | expect(q.get('asset')).toBe('stock') |
| 59 | 72 | expect(q.get('ticker')).toBe('AAPL') |
| 60 | 73 | expect(q.get('timeframe')).toBe('1day') |
| 61 | − // errors section links to anchors on the errors page | |
| 62 | 74 | await expect(page.locator('#errors')).toBeVisible() |
| 63 | 75 | await expect(page.getByRole('link', { name: 'RATE_LIMIT_EXCEEDED' }).first()).toHaveAttribute('href', '/docs/errors#rate_limit_exceeded') |
| 64 | 76 | }) |
| 65 | 77 | |
| 78 | + test('operation page: Run live on a 429 says rate limited, not down', async ({ page }) => { | |
| 79 | + await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL' | |
| 80 | + ? json({ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests' } }, 429, { 'Retry-After': '90' }) : null)) | |
| 81 | + await page.goto('/docs/reference/bars_v1_bars__asset___ticker__get') | |
| 82 | + await page.getByTestId('run-live').click() | |
| 83 | + await expect(page.getByTestId('response-example')).toContainText('Rate limited — not down') | |
| 84 | + await expect(page.getByTestId('response-example').getByRole('link', { name: /free account/ })).toBeVisible() | |
| 85 | + await expect(page.getByTestId('run-live')).toBeDisabled() | |
| 86 | + }) | |
| 87 | + | |
| 88 | + test('code blocks: wrap toggle persists; heading anchors carry no "#" text', async ({ page }) => { | |
| 89 | + await page.goto('/docs/quickstart') | |
| 90 | + const block = page.getByRole('complementary', { name: 'Code and outline' }).locator('.code').first() | |
| 91 | + await expect(block).not.toHaveClass(/code-wrap/) | |
| 92 | + await block.getByRole('button', { name: 'Wrap long lines' }).click() | |
| 93 | + await expect(block).toHaveClass(/code-wrap/) | |
| 94 | + expect(await page.evaluate(() => localStorage.getItem('hfmd.codewrap'))).toBe('1') | |
| 95 | + await page.reload() | |
| 96 | + await expect(page.getByRole('complementary', { name: 'Code and outline' }).locator('.code').first()).toHaveClass(/code-wrap/) | |
| 97 | + const texts = await page.locator('.docs-content h2').evaluateAll(els => els.map(e => e.textContent)) | |
| 98 | + for (const t of texts) expect(t.trim().startsWith('#')).toBe(false) | |
| 99 | + await page.evaluate(() => localStorage.removeItem('hfmd.codewrap')) | |
| 100 | + }) | |
| 101 | + | |
| 66 | 102 | test('errors page has an anchor per code', async ({ page }) => { |
| 67 | 103 | await page.goto('/docs/errors#rate_limit_exceeded') |
| 68 | 104 | await expect(page.getByTestId('errors-page')).toBeVisible() |
@@ -70,6 +106,7 @@ test.describe('docs', () => { | ||
| 70 | 106 | await expect(page.locator(`#${id}`)).toBeAttached() |
| 71 | 107 | } |
| 72 | 108 | await expect(page.locator('#rate_limit_exceeded')).toContainText('RATE_LIMIT_EXCEEDED') |
| 109 | + expect((await page.locator('#rate_limit_exceeded h3').textContent()).trim().endsWith('#')).toBe(false) | |
| 73 | 110 | }) |
| 74 | 111 | |
| 75 | 112 | test('Cmd+K search: open, type, keyboard navigate, Enter opens the page, Esc closes', async ({ page }) => { |
@@ -86,7 +123,6 @@ test.describe('docs', () => { | ||
| 86 | 123 | await page.keyboard.press('Enter') |
| 87 | 124 | await expect(dialog).toBeHidden() |
| 88 | 125 | await expect(page).toHaveURL(/\/docs\/futures-contracts/) |
| 89 | − // slash shortcut + Escape | |
| 90 | 126 | await page.keyboard.press('/') |
| 91 | 127 | await expect(page.getByTestId('search-dialog')).toBeVisible() |
| 92 | 128 | await expect(page.getByTestId('search-dialog').getByRole('combobox')).toBeFocused() |
@@ -94,7 +130,6 @@ test.describe('docs', () => { | ||
| 94 | 130 | await expect(page.getByTestId('search-dialog').getByRole('option').first()).toContainText(/Opt Chain|chain/i) |
| 95 | 131 | await page.keyboard.press('Escape') |
| 96 | 132 | await expect(page.getByTestId('search-dialog')).toBeHidden() |
| 97 | − // trigger button also opens it | |
| 98 | 133 | await page.getByTestId('search-trigger').click() |
| 99 | 134 | await expect(page.getByTestId('search-dialog')).toBeVisible() |
| 100 | 135 | }) |
@@ -106,9 +141,7 @@ test.describe('docs', () => { | ||
| 106 | 141 | await expect(page.locator('.callout').first()).toBeVisible() |
| 107 | 142 | await expect(page.locator('.figure svg').first()).toBeVisible() |
| 108 | 143 | await expect(page.locator('.docs-content table').first()).toBeVisible() |
| 109 | − // On this page outline lists the headings | |
| 110 | 144 | await expect(page.getByRole('navigation', { name: 'On this page' }).getByRole('link', { name: /Adjustment methods/ })).toBeVisible() |
| 111 | − // Edit / report link is a mailto with the page URL | |
| 112 | 145 | const mail = await page.getByRole('link', { name: /Edit \/ Report an issue/ }).getAttribute('href') |
| 113 | 146 | expect(mail).toMatch(/^mailto:contact@spboucher\.ai\?subject=/) |
| 114 | 147 | expect(decodeURIComponent(mail)).toContain('/docs/futures-contracts') |
@@ -117,5 +150,7 @@ test.describe('docs', () => { | ||
| 117 | 150 | test('changelog has the v2.0.0 entry dated 2026-09-04', async ({ page }) => { |
| 118 | 151 | await page.goto('/docs/changelog') |
| 119 | 152 | await expect(page.getByRole('heading', { level: 2, name: /2\.0\.0 — 2026-09-04/ })).toBeVisible() |
| 153 | + await page.goto('/changelog') | |
| 154 | + await expect(page).toHaveURL(/\/docs\/changelog$/) | |
| 120 | 155 | }) |
| 121 | 156 | }) |
modified
hfmarketdata/web/e2e/mobile.spec.js
+45 −7
@@ -1,29 +1,67 @@ | ||
| 1 | −// Mobile (Pixel 7): drawer navigation, docs nav toggle, code panel below content (DOM order = visual order). | |
| 1 | +// Mobile (Pixel 7): drawer navigation, docs nav toggle + inline panel order, playground form + sticky Run, | |
| 2 | +// tap targets ≥ 44 px, no horizontal overflow anywhere. | |
| 2 | 3 | import { expect, test } from '@playwright/test' |
| 4 | +import { mockAnon } from './mocks.js' | |
| 5 | + | |
| 6 | +const noOverflow = page => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) | |
| 3 | 7 | |
| 4 | 8 | test.describe('mobile', () => { |
| 5 | − test('drawer opens from the menu button and navigates', async ({ page }) => { | |
| 9 | + test('drawer opens from the menu button and navigates; header tap targets ≥ 44 px', async ({ page }) => { | |
| 6 | 10 | await page.goto('/') |
| 7 | 11 | await expect(page.getByRole('navigation', { name: 'Main' })).toBeHidden() |
| 12 | + const small = await page.evaluate(() => [...document.querySelectorAll('header a, header button')].filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && (r.height < 44 || r.width < 44) }).length) | |
| 13 | + expect(small).toBe(0) | |
| 8 | 14 | await page.getByRole('button', { name: 'Open menu' }).click() |
| 9 | 15 | const drawer = page.getByRole('navigation', { name: 'Mobile' }) |
| 10 | 16 | await expect(drawer).toBeVisible() |
| 17 | + await expect(drawer.getByRole('link', { name: 'Changelog' })).toBeVisible() | |
| 11 | 18 | await drawer.getByRole('link', { name: 'Limits' }).click() |
| 12 | 19 | await expect(page).toHaveURL(/\/limits$/) |
| 13 | 20 | await expect(drawer).toBeHidden() |
| 14 | 21 | }) |
| 15 | 22 | |
| 16 | − test('docs: nav toggle, code panel after content', async ({ page }) => { | |
| 23 | + test('no horizontal overflow on the public pages', async ({ page }) => { | |
| 24 | + await mockAnon(page) | |
| 25 | + for (const path of ['/', '/docs', '/docs/quickstart', '/docs/reference/bars_v1_bars__asset___ticker__get', '/playground', '/integrations', '/limits', '/status', '/terms', '/nope']) { | |
| 26 | + await page.goto(path) | |
| 27 | + await page.waitForTimeout(300) | |
| 28 | + expect(await noOverflow(page), path).toBe(true) | |
| 29 | + } | |
| 30 | + }) | |
| 31 | + | |
| 32 | + test('docs: nav toggle; request/response panel right after the intro, before Parameters', async ({ page }) => { | |
| 17 | 33 | await page.goto('/docs/quickstart') |
| 18 | 34 | await expect(page.getByRole('navigation', { name: 'Documentation' })).toBeHidden() |
| 19 | 35 | await page.getByRole('button', { name: /Docs menu/ }).click() |
| 20 | 36 | await expect(page.getByRole('navigation', { name: 'Documentation' })).toBeVisible() |
| 21 | 37 | await page.getByRole('navigation', { name: 'Documentation' }).getByRole('link', { name: 'Rate limits' }).click() |
| 22 | 38 | await expect(page).toHaveURL(/\/docs\/rate-limits$/) |
| 23 | − const content = page.locator('.docs-content') | |
| 24 | − const panel = page.getByRole('complementary', { name: 'Code and outline' }) | |
| 25 | − const [c, p] = await Promise.all([content.boundingBox(), panel.boundingBox()]) | |
| 26 | − expect(p.y).toBeGreaterThan(c.y) // panel rendered below the article on small screens | |
| 39 | + await expect(page.getByRole('complementary', { name: 'Code and outline' })).toHaveCount(0) | |
| 40 | + await expect(page.getByTestId('docs-panel-inline')).toBeVisible() | |
| 41 | + // operation page: inline panel between the header actions and the Parameters heading | |
| 42 | + await page.goto('/docs/reference/bars_v1_bars__asset___ticker__get') | |
| 43 | + const [tryIt, panel, params, prevNext] = await Promise.all([ | |
| 44 | + page.getByTestId('try-it').boundingBox(), page.getByTestId('docs-panel-inline').boundingBox(), | |
| 45 | + page.locator('#parameters').boundingBox(), page.getByRole('navigation', { name: 'Previous / next' }).boundingBox(), | |
| 46 | + ]) | |
| 47 | + expect(panel.y).toBeGreaterThan(tryIt.y) | |
| 48 | + expect(params.y).toBeGreaterThan(panel.y) | |
| 49 | + expect(prevNext.y).toBeGreaterThan(params.y) | |
| 50 | + // parameters table is readable: no cell narrower than its label column, no horizontal overflow | |
| 51 | + expect(await noOverflow(page)).toBe(true) | |
| 52 | + }) | |
| 53 | + | |
| 54 | + test('playground: form visible near the top, Run reachable, results below', async ({ page }) => { | |
| 55 | + await mockAnon(page) | |
| 56 | + await page.goto('/playground') | |
| 57 | + const form = await page.getByTestId('pg-form').boundingBox() | |
| 58 | + expect(form.y).toBeLessThan(900) | |
| 59 | + const run = page.getByTestId('pg-run') | |
| 60 | + await run.scrollIntoViewIfNeeded() | |
| 61 | + const box = await run.boundingBox() | |
| 62 | + expect(box.height).toBeGreaterThanOrEqual(44) | |
| 63 | + await expect(page.getByTestId('pg-sample')).toBeVisible() | |
| 64 | + expect(await noOverflow(page)).toBe(true) | |
| 27 | 65 | }) |
| 28 | 66 | |
| 29 | 67 | test('search dialog usable on touch viewport', async ({ page }) => { |
modified
hfmarketdata/web/e2e/playground.spec.js
+80 −27
@@ -1,3 +1,5 @@ | ||
| 1 | +// Playground: endpoint picker, form, deep links (incl. auto-run and dependent enums), captured example empty state, | |
| 2 | +// results views, code tab, 429 countdown, CSV download, WebSocket gating. | |
| 1 | 3 | import { expect, test } from '@playwright/test' |
| 2 | 4 | import { bars, json, mockAnon } from './mocks.js' |
| 3 | 5 | |
@@ -16,13 +18,28 @@ test.beforeEach(async ({ page }) => { | ||
| 16 | 18 | await page.route('**/openapi.json', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SPEC) })) |
| 17 | 19 | }) |
| 18 | 20 | |
| 19 | −test('keyless banner, default request and live URL preview', async ({ page }) => { | |
| 20 | − await mockAnon(page) | |
| 21 | +async function pickEndpoint(page, name) { | |
| 22 | + await page.getByTestId('pg-endpoint').click() | |
| 23 | + await page.getByTestId('pg-endpoint-search').fill(name) | |
| 24 | + await page.getByRole('listbox', { name: 'Endpoints' }).getByRole('option', { name: new RegExp(name.replace(/[()]/g, '\\$&'), 'i') }).first().click() | |
| 25 | +} | |
| 26 | + | |
| 27 | +test('keyless banner, default request, captured example as empty state, live URL preview, form above the fold', async ({ page }) => { | |
| 28 | + const calls = [] | |
| 29 | + await mockAnon(page, url => { calls.push(url.pathname); return null }) | |
| 21 | 30 | await page.goto('/playground') |
| 22 | 31 | await expect(page.getByText('Keyless mode:')).toBeVisible() |
| 23 | 32 | await expect(page.getByRole('link', { name: 'Create free account' }).first()).toBeVisible() |
| 33 | + // parameters form is visible without scrolling on desktop | |
| 34 | + const form = await page.getByTestId('pg-form').boundingBox() | |
| 35 | + expect(form.y + 100).toBeLessThan(page.viewportSize().height) | |
| 24 | 36 | const url = page.getByTestId('pg-url') |
| 25 | 37 | await expect(url).toContainText('https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day') |
| 38 | + // empty state = captured production example, no API call made | |
| 39 | + await expect(page.getByTestId('pg-sample')).toContainText('Edit a parameter and run') | |
| 40 | + await expect(page.getByRole('tab', { name: /^Table/ })).toHaveAttribute('aria-selected', 'true') | |
| 41 | + await expect(page.getByRole('region', { name: 'Results table' })).toContainText('AAPL') | |
| 42 | + expect(calls.filter(p => p !== '/v1/me')).toEqual([]) | |
| 26 | 43 | await page.getByLabel(/^ticker/).fill('MSFT') |
| 27 | 44 | await expect(url).toContainText('/v1/bars/stock/MSFT') |
| 28 | 45 | await expect(page).toHaveURL(/ep=stock-bars/) |
@@ -31,16 +48,31 @@ test('keyless banner, default request and live URL preview', async ({ page }) => | ||
| 31 | 48 | await expect(url).toContainText('/v1/bars/etf/MSFT') |
| 32 | 49 | }) |
| 33 | 50 | |
| 34 | −test('run → status, latency, rows, table / chart / json views and rate-limit panel', async ({ page }) => { | |
| 51 | +test('endpoint picker: search, keyboard, selection', async ({ page }) => { | |
| 52 | + await mockAnon(page) | |
| 53 | + await page.goto('/playground') | |
| 54 | + await page.getByTestId('pg-endpoint').click() | |
| 55 | + const search = page.getByTestId('pg-endpoint-search') | |
| 56 | + await expect(search).toBeFocused() | |
| 57 | + await search.fill('term') | |
| 58 | + await expect(page.getByRole('listbox', { name: 'Endpoints' }).getByRole('option')).toHaveCount(1) | |
| 59 | + await page.keyboard.press('Enter') | |
| 60 | + await expect(page.getByTestId('pg-endpoint')).toContainText('Term structure') | |
| 61 | + await expect(page.getByTestId('pg-url')).toContainText('/v1/futures/NG/term-structure') | |
| 62 | + await expect(page).toHaveURL(/ep=futures-term-structure/) | |
| 63 | +}) | |
| 64 | + | |
| 65 | +test('run → status, latency, rows, table / chart / json / code views and rate-limit panel', async ({ page }) => { | |
| 35 | 66 | const rows = bars(120) |
| 36 | 67 | await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL' ? json({ count: rows.length, data: rows }) : null)) |
| 37 | − await page.goto('/playground?ep=stock-bars&timeframe=1min') | |
| 68 | + await page.goto('/playground') | |
| 69 | + await page.getByLabel(/^timeframe/).selectOption('1min') | |
| 38 | 70 | await page.getByTestId('pg-run').click() |
| 39 | 71 | const status = page.getByTestId('pg-status') |
| 40 | 72 | await expect(status).toContainText('200 OK') |
| 41 | 73 | await expect(status).toContainText('120 rows') |
| 42 | 74 | await expect(status).toContainText(/\d+ ms/) |
| 43 | − // bars → chart by default; table + json available | |
| 75 | + await expect(page.getByTestId('pg-sample')).toHaveCount(0) | |
| 44 | 76 | await expect(page.getByRole('tab', { name: 'Chart' })).toHaveAttribute('aria-selected', 'true') |
| 45 | 77 | await expect(page.getByRole('img', { name: /Candlestick chart of 120 rows/ })).toBeVisible() |
| 46 | 78 | await page.getByRole('tab', { name: /^Table/ }).click() |
@@ -50,33 +82,49 @@ test('run → status, latency, rows, table / chart / json views and rate-limit p | ||
| 50 | 82 | await expect(page.getByText(/sorted by close asc/)).toBeVisible() |
| 51 | 83 | await page.getByRole('tab', { name: 'JSON' }).click() |
| 52 | 84 | await expect(page.getByText('120 rows in data[]')).toBeVisible() |
| 53 | − // rate-limit panel from headers | |
| 54 | 85 | const rl = page.getByRole('region', { name: 'Rate limit' }) |
| 55 | 86 | await expect(rl).toContainText('29 / 30 left') |
| 56 | 87 | await expect(rl).toContainText('99,500 / 100,000 left') |
| 57 | 88 | await expect(rl).toContainText('Window resets in') |
| 58 | − // code export without key → no Authorization header | |
| 59 | − await expect(page.locator('.pg-codex')).toContainText('curl "https://www.hfmarketdata.io/v1/bars/stock/AAPL') | |
| 60 | − await expect(page.locator('.pg-codex')).not.toContainText('Authorization') | |
| 61 | − await page.getByRole('tab', { name: /Python/ }).click() | |
| 62 | − await expect(page.locator('.pg-codex')).toContainText('pd.DataFrame(payload["data"])') | |
| 89 | + // code tab without key → no Authorization header | |
| 90 | + await page.getByRole('tab', { name: 'Code' }).click() | |
| 91 | + const code = page.getByTestId('pg-code') | |
| 92 | + await code.getByRole('radio', { name: 'curl' }).click() | |
| 93 | + await expect(code).toContainText('curl "https://www.hfmarketdata.io/v1/bars/stock/AAPL') | |
| 94 | + await expect(code).not.toContainText('Authorization') | |
| 95 | + await code.getByRole('radio', { name: 'Python' }).click() | |
| 96 | + await expect(code).toContainText('pd.DataFrame(payload["data"])') | |
| 63 | 97 | }) |
| 64 | 98 | |
| 65 | −test('deep link by operationId selects the catalog entry and fills params', async ({ page }) => { | |
| 66 | − await mockAnon(page) | |
| 99 | +test('deep link by operationId selects the catalog entry, fills params and auto-runs once', async ({ page }) => { | |
| 100 | + let hits = 0 | |
| 101 | + await mockAnon(page, url => { if (url.pathname === '/v1/options/chain/TSLA') { hits++; return json({ count: 1, data: [{ strike: 200, delta: 0.5 }] }) } return null }) | |
| 67 | 102 | await page.goto('/playground?ep=opt_chain_v1_options_chain__ticker__get&ticker=TSLA&trade_date=2024-06-21&call_put=p') |
| 68 | − await expect(page.getByRole('button', { name: /Options chain \(Greeks\)/ })).toHaveAttribute('aria-current', 'true') | |
| 103 | + await expect(page.getByTestId('pg-endpoint')).toContainText('Options chain (Greeks)') | |
| 69 | 104 | await expect(page.getByTestId('pg-url')).toContainText('/v1/options/chain/TSLA?trade_date=2024-06-21') |
| 70 | 105 | await expect(page.getByTestId('pg-url')).toContainText('call_put=p') |
| 71 | − // URL is normalised to the catalog id | |
| 72 | 106 | await expect(page).toHaveURL(/ep=options-chain/) |
| 107 | + await expect(page.getByTestId('pg-status')).toContainText('200 OK') | |
| 108 | + await page.waitForTimeout(300) | |
| 109 | + expect(hits).toBe(1) | |
| 73 | 110 | }) |
| 74 | 111 | |
| 75 | −test('deep link with fixed asset picks the crypto entry', async ({ page }) => { | |
| 112 | +test('deep link with fixed asset picks the crypto entry; futures deep link from the home is runnable', async ({ page }) => { | |
| 76 | 113 | await mockAnon(page) |
| 77 | 114 | await page.goto('/playground?ep=bars_v1_bars__asset___ticker__get&asset=crypto&ticker=ETHUSD') |
| 78 | − await expect(page.getByRole('button', { name: /Crypto bars/ })).toHaveAttribute('aria-current', 'true') | |
| 115 | + await expect(page.getByTestId('pg-endpoint')).toContainText('Crypto bars') | |
| 79 | 116 | await expect(page.getByTestId('pg-url')).toContainText('/v1/bars/crypto/ETHUSD') |
| 117 | + // asset=futures resolves to the futures entry, dependent enums are recomputed, Run is enabled (regression: "Not an allowed value") | |
| 118 | + await page.goto('/playground?ep=bars_v1_bars__asset___ticker__get&asset=futures&ticker=ES&timeframe=1day&limit=5') | |
| 119 | + await expect(page.getByTestId('pg-endpoint')).toContainText('Futures continuous') | |
| 120 | + await expect(page.getByTestId('pg-url')).toContainText('/v1/bars/futures/ES?adjustment=contin_adj_ratio&timeframe=1day') | |
| 121 | + await expect(page.getByText('Not an allowed value')).toHaveCount(0) | |
| 122 | + await expect(page.getByTestId('pg-run')).toBeEnabled() | |
| 123 | + // multi-ticker with asset=futures from the URL: adjustment enum switches to futures values | |
| 124 | + await page.goto('/playground?ep=bars-multi&asset=futures&tickers=ES,CL&adjustment=adj_split') | |
| 125 | + await expect(page.getByText('Not an allowed value')).toHaveCount(0) | |
| 126 | + await expect(page.getByLabel(/^adjustment/)).toHaveValue('') | |
| 127 | + await expect(page.getByTestId('pg-run')).toBeEnabled() | |
| 80 | 128 | }) |
| 81 | 129 | |
| 82 | 130 | test('unknown operationId falls back to a generic form built from /openapi.json', async ({ page }) => { |
@@ -96,15 +144,18 @@ test('presets: CL chain and ES continuous', async ({ page }) => { | ||
| 96 | 144 | await expect(page.getByTestId('pg-url')).toContainText('/v1/futures/ES/continuous?roll=volume&adjust=back_adjusted&depth=1&interval=1d&from=2015-01-01&to=2025-12-31') |
| 97 | 145 | }) |
| 98 | 146 | |
| 99 | −test('429 shows a Retry-After countdown; missing route explains "coming soon"', async ({ page }) => { | |
| 147 | +test('429 shows a countdown + free-account CTA; missing route explains "coming soon"', async ({ page }) => { | |
| 100 | 148 | await mockAnon(page, url => { |
| 101 | 149 | if (url.pathname === '/v1/bars/stock/AAPL') return json({ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests', docs: 'https://www.hfmarketdata.io/docs/errors#RATE_LIMIT_EXCEEDED' }, detail: 'Too many requests' }, 429, { 'Retry-After': '90', 'X-RateLimit-Remaining-Requests': '0' }) |
| 102 | 150 | return null |
| 103 | 151 | }) |
| 104 | − await page.goto('/playground?ep=stock-bars') | |
| 152 | + await page.goto('/playground') | |
| 105 | 153 | await page.getByTestId('pg-run').click() |
| 106 | 154 | await expect(page.getByTestId('pg-status')).toContainText('429') |
| 107 | − await expect(page.getByText('RATE_LIMIT_EXCEEDED · HTTP 429')).toBeVisible() | |
| 155 | + const err = page.getByTestId('pg-error') | |
| 156 | + await expect(err).toContainText('Rate limited — not down') | |
| 157 | + await expect(err.locator('.countdown')).toContainText(/1 min \d+ s/) | |
| 158 | + await expect(err.getByRole('link', { name: /Create free account/ })).toBeVisible() | |
| 108 | 159 | await expect(page.getByRole('region', { name: 'Rate limit' })).toContainText(/Retry in 1 min \d+ s/) |
| 109 | 160 | // v2 endpoint not deployed → coming soon |
| 110 | 161 | await page.getByRole('button', { name: 'NG term structure' }).click() |
@@ -113,13 +164,13 @@ test('429 shows a Retry-After countdown; missing route explains "coming soon"', | ||
| 113 | 164 | await expect(page.getByText(/coming soon/)).toBeVisible() |
| 114 | 165 | }) |
| 115 | 166 | |
| 116 | −test('validation blocks Run; WebSocket tab is disabled without a key', async ({ page }) => { | |
| 167 | +test('validation blocks Run; WebSocket needs a key', async ({ page }) => { | |
| 117 | 168 | await mockAnon(page) |
| 118 | 169 | await page.goto('/playground?ep=options-history') |
| 119 | 170 | await page.getByLabel(/^strike/).fill('') |
| 120 | 171 | await expect(page.getByTestId('pg-run')).toBeDisabled() |
| 121 | 172 | await expect(page.getByText('Required')).toBeVisible() |
| 122 | − await page.getByRole('button', { name: /WebSocket stream/ }).click() | |
| 173 | + await pickEndpoint(page, 'WebSocket stream') | |
| 123 | 174 | await expect(page.getByText('WebSocket streaming needs an API key')).toBeVisible() |
| 124 | 175 | await expect(page.getByRole('link', { name: 'Create free account' }).last()).toBeVisible() |
| 125 | 176 | }) |
@@ -128,10 +179,12 @@ test('CSV format gives a download and a parsed table', async ({ page }) => { | ||
| 128 | 179 | await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL' && url.searchParams.get('format') === 'csv' |
| 129 | 180 | ? { status: 200, contentType: 'text/csv', headers: { 'X-Row-Count': '2' }, body: 'ticker,datetime,open,high,low,close,volume\nAAPL,2024-06-03,1,2,0.5,1.5,100\nAAPL,2024-06-04,1.5,2.5,1,2,200\n' } : null)) |
| 130 | 181 | await page.goto('/playground?ep=stock-bars&format=csv') |
| 131 | − await page.getByTestId('pg-run').click() | |
| 132 | − await expect(page.getByTestId('pg-status')).toContainText('2 rows') | |
| 182 | + await expect(page.getByTestId('pg-status')).toContainText('2 rows') // auto-run on a valid deep link | |
| 133 | 183 | await expect(page.getByRole('link', { name: 'Download CSV' })).toBeVisible() |
| 134 | − await expect(page.locator('.pg-codex')).toContainText('-o data.csv') | |
| 135 | − await page.getByRole('tab', { name: /Python/ }).click() | |
| 136 | − await expect(page.locator('.pg-codex')).toContainText('pd.read_csv') | |
| 184 | + await page.getByRole('tab', { name: 'Code' }).click() | |
| 185 | + const code = page.getByTestId('pg-code') | |
| 186 | + await code.getByRole('radio', { name: 'curl' }).click() | |
| 187 | + await expect(code).toContainText('-o data.csv') | |
| 188 | + await code.getByRole('radio', { name: 'Python' }).click() | |
| 189 | + await expect(code).toContainText('pd.read_csv') | |
| 137 | 190 | }) |
modified
hfmarketdata/web/e2e/site.spec.js
+107 −17
@@ -1,25 +1,49 @@ | ||
| 1 | −// Site shell: homepage, limits page content (free platform), theme toggle persistence, status page, prerendered shells. | |
| 1 | +// Site shell: homepage (no API calls except /v1/status), limits, theme, status (429 ≠ down), 404, legal, prerender, a11y. | |
| 2 | 2 | import { expect, test } from '@playwright/test' |
| 3 | +import { json, mockAnon } from './mocks.js' | |
| 4 | + | |
| 5 | +const noOverflow = page => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) | |
| 3 | 6 | |
| 4 | 7 | test.describe('site', () => { |
| 5 | − test('homepage hero, nav and key sections', async ({ page }) => { | |
| 8 | + test('homepage: hero with captured response, nav, sections, no horizontal overflow', async ({ page }) => { | |
| 9 | + const calls = [] | |
| 10 | + await mockAnon(page, url => { calls.push(url.pathname); return null }) | |
| 6 | 11 | await page.goto('/') |
| 7 | 12 | await expect(page.getByRole('heading', { level: 1 })).toContainText('Open high-frequency market data') |
| 8 | 13 | const nav = page.getByRole('navigation', { name: 'Main' }) |
| 9 | 14 | for (const label of ['Docs', 'Playground', 'Integrations', 'Limits', 'Status']) await expect(nav.getByRole('link', { name: label })).toBeVisible() |
| 10 | 15 | await expect(nav.getByRole('link', { name: /Pricing/ })).toHaveCount(0) |
| 11 | − await expect(page.getByRole('heading', { name: /Get an API key/ })).toBeVisible() | |
| 16 | + // hero shows the captured AAPL response and a request in four languages | |
| 17 | + await expect(page.getByTestId('hero-response')).toContainText('"ticker": "AAPL"') | |
| 18 | + await expect(page.locator('.hero-code').getByRole('radio', { name: 'R', exact: true })).toBeVisible() | |
| 19 | + await expect(page.getByRole('heading', { name: /Start with a URL/ })).toBeVisible() | |
| 12 | 20 | await expect(page.getByRole('heading', { name: /A real request, a real response/ })).toBeVisible() |
| 13 | 21 | await expect(page.getByRole('heading', { name: /SEC EDGAR, point-in-time/ })).toBeVisible() |
| 14 | − await expect(page.getByTestId('pe-chart')).toBeVisible() | |
| 15 | − // preview falls back to the captured ES response when the API is not reachable | |
| 16 | − await expect(page.locator('.preview .code').nth(1)).toContainText('"ticker": "ES"') | |
| 17 | 22 | await expect(page.getByRole('link', { name: 'Request higher limits' })).toHaveAttribute('href', /^mailto:contact@spboucher\.ai/) |
| 23 | + // counters fall back to the captured /v1/status silently (no apology text) | |
| 24 | + await expect(page.getByTestId('counters')).toContainText('7,665') | |
| 25 | + await expect(page.locator('main')).not.toContainText(/not reachable|did not complete/) | |
| 26 | + // footer: legal + changelog + author | |
| 27 | + const footer = page.getByRole('contentinfo') | |
| 28 | + for (const l of ['Terms', 'Privacy', 'Data license & attribution', 'Changelog', 'Status', 'Playground']) await expect(footer.getByRole('link', { name: l })).toBeVisible() | |
| 29 | + await expect(footer).toContainText('Built by Simon-Pierre Boucher') | |
| 30 | + expect(await noOverflow(page)).toBe(true) | |
| 31 | + // the home makes at most one API call on load: /v1/status (quota-exempt) | |
| 32 | + await page.waitForTimeout(500) | |
| 33 | + expect(calls.filter(p => p !== '/v1/me' && p !== '/v1/status')).toEqual([]) | |
| 34 | + }) | |
| 35 | + | |
| 36 | + test('homepage charts render from bundled data when scrolled into view', async ({ page }) => { | |
| 37 | + await mockAnon(page) | |
| 38 | + await page.goto('/') | |
| 39 | + await page.getByTestId('preview-chart').scrollIntoViewIfNeeded() | |
| 40 | + await expect(page.getByTestId('preview-chart').locator('canvas').first()).toBeVisible({ timeout: 15_000 }) | |
| 41 | + await page.getByTestId('pe-chart').scrollIntoViewIfNeeded() | |
| 42 | + await expect(page.getByTestId('pe-chart').locator('canvas').first()).toBeVisible({ timeout: 15_000 }) | |
| 43 | + await expect(page.locator('main')).not.toContainText(/not reachable/) | |
| 18 | 44 | }) |
| 19 | 45 | |
| 20 | 46 | test('limits page: three levels with all numbers, CTAs, no paid wording', async ({ page }) => { |
| 21 | − // /pricing is the route App.jsx currently serves; it becomes a redirect to /limits (web-app coordinator), so | |
| 22 | − // navigating here works before and after that change. Switch to '/limits' once the redirect is in. | |
| 23 | 47 | await page.goto('/limits') |
| 24 | 48 | await expect(page.getByTestId('limits-page')).toBeVisible() |
| 25 | 49 | await expect(page.getByRole('heading', { level: 1 })).toHaveText('Free for everyone.') |
@@ -32,16 +56,16 @@ test.describe('site', () => { | ||
| 32 | 56 | await expect(page.getByTestId('cta-signup')).toHaveAttribute('href', '/signup') |
| 33 | 57 | const mail = await page.getByTestId('cta-high-usage').getAttribute('href') |
| 34 | 58 | expect(mail).toMatch(/^mailto:contact@spboucher\.ai\?subject=/) |
| 35 | − // incentives + FAQ | |
| 36 | 59 | await expect(page.getByText('Parquet responses count half', { exact: true })).toBeVisible() |
| 37 | 60 | await page.getByText('What happens on 429?').click() |
| 38 | 61 | await expect(page.getByText(/RATE_LIMIT_EXCEEDED/).first()).toBeVisible() |
| 39 | 62 | const text = (await page.locator('main').innerText()).toLowerCase() |
| 40 | 63 | for (const banned of ['pricing', 'paid', 'per month', '$/']) expect(text).not.toContain(banned) |
| 41 | 64 | await expect(page).toHaveTitle(/Access & limits — free for everyone/) |
| 65 | + expect(await noOverflow(page)).toBe(true) | |
| 42 | 66 | }) |
| 43 | 67 | |
| 44 | − test('theme toggle switches and persists across reloads', async ({ page }) => { | |
| 68 | + test('theme toggle switches and persists across reloads; code blocks stay readable in light mode', async ({ page }) => { | |
| 45 | 69 | await page.goto('/') |
| 46 | 70 | const html = page.locator('html') |
| 47 | 71 | await expect(html).toHaveAttribute('data-theme', 'dark') |
@@ -50,42 +74,108 @@ test.describe('site', () => { | ||
| 50 | 74 | expect(await page.evaluate(() => localStorage.getItem('hfmd.theme'))).toBe('light') |
| 51 | 75 | await page.reload() |
| 52 | 76 | await expect(html).toHaveAttribute('data-theme', 'light') |
| 53 | − // applied before React mounts (inline script in index.html) | |
| 54 | 77 | const early = await page.evaluate(() => document.documentElement.getAttribute('data-theme')) |
| 55 | 78 | expect(early).toBe('light') |
| 79 | + // code block background follows the theme (no hard-coded dark palette) | |
| 80 | + const bg = await page.getByTestId('hero-response').evaluate(el => getComputedStyle(el).backgroundColor) | |
| 81 | + expect(bg).not.toBe('rgb(1, 22, 39)') | |
| 82 | + const [r, g, b] = bg.match(/\d+/g).map(Number) | |
| 83 | + expect(r + g + b).toBeGreaterThan(600) | |
| 56 | 84 | await page.getByTestId('theme-toggle').click() |
| 57 | 85 | await expect(html).toHaveAttribute('data-theme', 'dark') |
| 58 | 86 | }) |
| 59 | 87 | |
| 60 | − test('status page shows health cards and handles an unreachable API', async ({ page }) => { | |
| 88 | + test('status page: health cards, unreachable API', async ({ page }) => { | |
| 61 | 89 | await page.goto('/status') |
| 62 | 90 | await expect(page.getByTestId('status-page')).toBeVisible() |
| 63 | 91 | await expect(page.getByRole('heading', { level: 1 })).toContainText('API health') |
| 64 | 92 | await expect(page.locator('.health-card')).toHaveCount(4) |
| 65 | − // vite preview has no API → cards settle to Down, warning callout shown, page still usable | |
| 93 | + // vite preview has no API → cards settle to Down, alert shown, page still usable | |
| 66 | 94 | await expect(page.locator('.health-card[data-state="down"]').first()).toBeVisible({ timeout: 15_000 }) |
| 67 | 95 | await expect(page.getByRole('alert')).toContainText('did not answer') |
| 68 | 96 | }) |
| 69 | 97 | |
| 70 | − test('prerendered shells carry route meta and preload the route chunk', async ({ request }) => { | |
| 98 | + test('status page: a 429 is reported as rate limited, not down; inventory renders from /v1/status', async ({ page }) => { | |
| 99 | + await page.route('**/health', r => r.fulfill(json({ status: 'ok', service: 'hfmarketdata-api', version: '2.0.0', data_root_present: true }))) | |
| 100 | + await page.route('**/openapi.json', r => r.fulfill({ status: 429, contentType: 'application/json', body: '{}' })) | |
| 101 | + await mockAnon(page, url => { | |
| 102 | + if (url.pathname === '/v1/status') return json({ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests' }, detail: 'Too many requests' }, 429, { 'Retry-After': '60' }) | |
| 103 | + return null | |
| 104 | + }) | |
| 105 | + await page.goto('/status') | |
| 106 | + await expect(page.getByTestId('status-limited')).toBeVisible() | |
| 107 | + await expect(page.getByTestId('status-overall')).toContainText('rate limited') | |
| 108 | + await expect(page.locator('.health-card[data-state="limited"]')).toHaveCount(2) | |
| 109 | + await expect(page.locator('.health-card[data-state="down"]')).toHaveCount(0) | |
| 110 | + // now a healthy inventory with a last-date field the API may add | |
| 111 | + await page.unroute('**/v1/**') | |
| 112 | + await mockAnon(page, url => (url.pathname === '/v1/status' | |
| 113 | + ? json({ datasets: { stock: { '1day': { adj_split: 7664, UNADJUSTED: 7665, last_date: '2026-09-05' }, '1min': { adj_split: 7670 } }, options: { quarters: ['2010_q1', '2026_q3_partial'], files_latest_quarter: 6056 } } }) | |
| 114 | + : null)) | |
| 115 | + await page.getByRole('button', { name: 'Re-check' }).click() | |
| 116 | + await expect(page.getByTestId('inv-stock')).toContainText('7,664') | |
| 117 | + await expect(page.getByTestId('last-dates')).toContainText('2026-09-05') | |
| 118 | + await expect(page.getByTestId('inv-options')).toContainText('6,056') | |
| 119 | + }) | |
| 120 | + | |
| 121 | + test('404 page has a title, search and links', async ({ page }) => { | |
| 122 | + await page.goto('/this/does/not/exist') | |
| 123 | + await expect(page.getByTestId('not-found')).toBeVisible() | |
| 124 | + await expect(page).toHaveTitle(/Page not found/) | |
| 125 | + await expect(page.getByRole('heading', { level: 1 })).toContainText('/this/does/not/exist') | |
| 126 | + for (const l of ['Documentation', 'Playground', 'Status']) await expect(page.getByTestId('not-found').getByRole('link', { name: new RegExp(l) })).toBeVisible() | |
| 127 | + await page.getByLabel('Search the documentation').fill('rate limits') | |
| 128 | + await page.getByTestId('not-found').getByRole('button', { name: 'Search' }).click() | |
| 129 | + const dialog = page.getByTestId('search-dialog') | |
| 130 | + await expect(dialog).toBeVisible() | |
| 131 | + await expect(dialog.getByRole('combobox')).toHaveValue('rate limits') | |
| 132 | + await expect(dialog.getByRole('option').first()).toContainText(/Rate limits/i) | |
| 133 | + }) | |
| 134 | + | |
| 135 | + test('legal pages render and cross-link', async ({ page }) => { | |
| 136 | + await page.goto('/terms') | |
| 137 | + await expect(page.getByTestId('legal-terms')).toBeVisible() | |
| 138 | + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Terms of use') | |
| 139 | + await expect(page.getByRole('heading', { name: /Reasonable use/ })).toBeVisible() | |
| 140 | + await page.getByRole('navigation', { name: 'Legal pages' }).getByRole('link', { name: 'Privacy' }).click() | |
| 141 | + await expect(page).toHaveURL(/\/privacy$/) | |
| 142 | + await expect(page.locator('main')).toContainText('Resend') | |
| 143 | + await expect(page.locator('main')).toContainText(/no third-party/i) | |
| 144 | + await page.goto('/data-license') | |
| 145 | + await expect(page.locator('main')).toContainText('FirstRate Data') | |
| 146 | + await expect(page.locator('main')).toContainText('public domain') | |
| 147 | + await expect(page).toHaveTitle(/Data license/) | |
| 148 | + }) | |
| 149 | + | |
| 150 | + test('prerendered shells carry route meta and preload the route chunk; every guide is prerendered', async ({ request }) => { | |
| 71 | 151 | const limits = await (await request.get('/limits')).text() |
| 72 | 152 | expect(limits).toContain('<title>Access & limits — free for everyone · HF Market Data</title>') |
| 73 | 153 | expect(limits).toContain('<link rel="canonical" href="https://www.hfmarketdata.io/limits" />') |
| 74 | − expect(limits).toMatch(/<link rel="modulepreload" crossorigin href="\/assets\/(Limits|Pricing)-[^"]+\.js">/) | |
| 154 | + expect(limits).toMatch(/<link rel="modulepreload" crossorigin href="\/assets\/Limits-[^"]+\.js">/) | |
| 75 | 155 | expect(limits).toContain('<h1>Free for everyone.</h1>') |
| 76 | 156 | const home = await (await request.get('/')).text() |
| 77 | 157 | expect(home).toMatch(/<link rel="modulepreload" crossorigin href="\/assets\/Home-[^"]+\.js">/) |
| 78 | 158 | expect(home).not.toContain('fonts.googleapis.com') |
| 159 | + expect(home).toContain('og.png') | |
| 160 | + expect(home).toContain('rel="manifest"') | |
| 161 | + const guide = await (await request.get('/docs/futures-contracts')).text() | |
| 162 | + expect(guide).toContain('<title>Futures: individual contracts · HF Market Data</title>') | |
| 163 | + expect(guide).toMatch(/modulepreload[^>]+futures-contracts-[^"]+\.js/) | |
| 164 | + const ref = await (await request.get('/docs/reference')).text() | |
| 165 | + expect(ref).toContain('<h1>API reference</h1>') | |
| 79 | 166 | const sitemap = await (await request.get('/sitemap.xml')).text() |
| 80 | − expect(sitemap).toContain('<loc>https://www.hfmarketdata.io/docs/quickstart</loc>') | |
| 167 | + for (const u of ['/docs/quickstart', '/docs/recipes/pandas-backtest', '/docs/reference', '/terms', '/privacy', '/data-license', '/playground']) expect(sitemap).toContain(`<loc>https://www.hfmarketdata.io${u}</loc>`) | |
| 168 | + for (const asset of ['/og.png', '/apple-touch-icon.png', '/icon-192.png', '/manifest.webmanifest', '/fonts/jetbrains-mono-latin.woff2']) expect((await request.get(asset)).status()).toBe(200) | |
| 81 | 169 | }) |
| 82 | 170 | |
| 83 | − test('accessibility basics: skip link, landmarks, focus-visible on nav', async ({ page }) => { | |
| 171 | + test('accessibility basics: skip link, landmarks, tap targets ≥ 36 px on desktop', async ({ page }) => { | |
| 84 | 172 | await page.goto('/limits') |
| 85 | 173 | await page.keyboard.press('Tab') |
| 86 | 174 | await expect(page.getByRole('link', { name: 'Skip to content' })).toBeFocused() |
| 87 | 175 | await expect(page.getByRole('banner')).toBeVisible() |
| 88 | 176 | await expect(page.getByRole('contentinfo')).toBeVisible() |
| 89 | 177 | await expect(page.getByRole('main')).toBeVisible() |
| 178 | + const small = await page.evaluate(() => [...document.querySelectorAll('header a, header button')].filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 36 }).length) | |
| 179 | + expect(small).toBe(0) | |
| 90 | 180 | }) |
| 91 | 181 | }) |
modified
hfmarketdata/web/index.html
+12 −4
@@ -2,7 +2,7 @@ | ||
| 2 | 2 | <html lang="en" data-theme="dark"> |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="UTF-8" /> |
| 5 | − <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> | |
| 6 | 6 | <meta name="color-scheme" content="dark light" /> |
| 7 | 7 | <meta name="theme-color" content="#0a0c10" media="(prefers-color-scheme: dark)" /> |
| 8 | 8 | <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> |
@@ -10,14 +10,22 @@ | ||
| 10 | 10 | <meta property="og:type" content="website" /> |
| 11 | 11 | <meta property="og:site_name" content="HF Market Data" /> |
| 12 | 12 | <meta property="og:title" content="HF Market Data — Open High-Frequency Market Data API" /> |
| 13 | − <meta property="og:description" content="1-minute to daily bars, futures contracts, options chains with Greeks and point-in-time fundamentals. No signup needed to start." /> | |
| 14 | − <meta property="og:image" content="https://www.hfmarketdata.io/og.svg" /> | |
| 13 | + <meta property="og:description" content="1-minute to daily bars, futures contracts, options chains with Greeks and point-in-time fundamentals. Free, no signup needed to start." /> | |
| 14 | + <meta property="og:image" content="https://www.hfmarketdata.io/og.png" /> | |
| 15 | + <meta property="og:image:width" content="1200" /> | |
| 16 | + <meta property="og:image:height" content="630" /> | |
| 17 | + <meta property="og:image:alt" content="HF Market Data — open high-frequency market data API" /> | |
| 15 | 18 | <meta name="twitter:card" content="summary_large_image" /> |
| 19 | + <meta name="twitter:image" content="https://www.hfmarketdata.io/og.png" /> | |
| 16 | 20 | <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> |
| 21 | + <link rel="icon" href="/icon-32.png" type="image/png" sizes="32x32" /> | |
| 22 | + <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" /> | |
| 23 | + <link rel="manifest" href="/manifest.webmanifest" /> | |
| 24 | + <link rel="preload" href="/fonts/jetbrains-mono-latin.woff2" as="font" type="font/woff2" crossorigin /> | |
| 17 | 25 | <link rel="canonical" href="https://www.hfmarketdata.io/" /> |
| 18 | 26 | <title>HF Market Data — Open High-Frequency Market Data API</title> |
| 19 | 27 | <script> |
| 20 | − // Theme is resolved before first paint (no flash): localStorage → system preference → dark. | |
| 28 | + // Theme is resolved before first paint (no flash): localStorage → dark. | |
| 21 | 29 | try { |
| 22 | 30 | var t = localStorage.getItem('hfmd.theme'); |
| 23 | 31 | if (t !== 'light' && t !== 'dark') t = 'dark'; |
added
hfmarketdata/web/public/apple-touch-icon.png
+0 −0
Binary file not shown.
modified
hfmarketdata/web/public/favicon.svg
+2 −2
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"> |
| 2 | − <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5ee7a5"/><stop offset="1" stop-color="#4db8ff"/></linearGradient></defs> | |
| 2 | + <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#34d399"/><stop offset="1" stop-color="#60a5fa"/></linearGradient></defs> | |
| 3 | 3 | <rect width="64" height="64" rx="14" fill="#0a0c10"/> |
| 4 | 4 | <path d="M10 44 L20 30 L28 36 L38 20 L46 26 L54 14" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/> |
| 5 | − <rect x="10" y="50" width="44" height="4" rx="2" fill="#5ee7a5" opacity=".5"/> | |
| 5 | + <rect x="10" y="50" width="44" height="4" rx="2" fill="#34d399" opacity=".5"/> | |
| 6 | 6 | </svg> |
added
hfmarketdata/web/public/fonts/jetbrains-mono-latin.woff2
+0 −0
Binary file not shown.
added
hfmarketdata/web/public/icon-192.png
+0 −0
Binary file not shown.
added
hfmarketdata/web/public/icon-32.png
+0 −0
Binary file not shown.
added
hfmarketdata/web/public/icon-512.png
+0 −0
Binary file not shown.
modified
hfmarketdata/web/public/logo.svg
+1 −1
@@ -1,5 +1,5 @@ | ||
| 1 | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 64" width="320" height="64" role="img" aria-label="HF Market Data"> |
| 2 | − <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5ee7a5"/><stop offset="1" stop-color="#4db8ff"/></linearGradient></defs> | |
| 2 | + <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#34d399"/><stop offset="1" stop-color="#60a5fa"/></linearGradient></defs> | |
| 3 | 3 | <rect width="64" height="64" rx="14" fill="#0a0c10"/> |
| 4 | 4 | <path d="M10 44 L20 30 L28 36 L38 20 L46 26 L54 14" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/> |
| 5 | 5 | <text x="80" y="41" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="28" font-weight="650" fill="#e8ebf1" letter-spacing="-0.5">HF Market Data</text> |
added
hfmarketdata/web/public/manifest.webmanifest
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +{ | |
| 2 | + "name": "HF Market Data", | |
| 3 | + "short_name": "HF Market Data", | |
| 4 | + "description": "Open high-frequency market data API — free, 1-minute to daily bars, futures contracts, options chains with Greeks, SEC EDGAR fundamentals.", | |
| 5 | + "start_url": "/", | |
| 6 | + "display": "standalone", | |
| 7 | + "background_color": "#0a0c10", | |
| 8 | + "theme_color": "#0a0c10", | |
| 9 | + "icons": [ | |
| 10 | + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, | |
| 11 | + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, | |
| 12 | + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } | |
| 13 | + ] | |
| 14 | +} | |
added
hfmarketdata/web/public/og.png
+0 −0
Binary file not shown.
deleted
hfmarketdata/web/public/og.svg
+0 −16
@@ -1,16 +0,0 @@ | ||
| 1 | −<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-label="HF Market Data — Open High-Frequency Market Data API"> | |
| 2 | − <defs> | |
| 3 | − <linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5ee7a5"/><stop offset="1" stop-color="#4db8ff"/></linearGradient> | |
| 4 | − <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0a0c10"/><stop offset="1" stop-color="#141a24"/></linearGradient> | |
| 5 | − </defs> | |
| 6 | − <rect width="1200" height="630" fill="url(#bg)"/> | |
| 7 | − <g stroke="#232833" stroke-width="1"> | |
| 8 | − <path d="M0 105H1200M0 210H1200M0 315H1200M0 420H1200M0 525H1200"/> | |
| 9 | − </g> | |
| 10 | − <path d="M80 470 L200 380 L300 420 L420 300 L540 340 L660 220 L780 280 L900 160 L1020 200 L1120 110" fill="none" stroke="url(#g)" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" opacity=".85"/> | |
| 11 | − <rect x="80" y="80" width="72" height="72" rx="16" fill="#0a0c10" stroke="#313847"/> | |
| 12 | − <path d="M92 130 L104 112 L114 120 L126 100 L136 108 L142 92" fill="none" stroke="url(#g)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/> | |
| 13 | − <text x="172" y="132" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="44" font-weight="700" fill="#e8ebf1" letter-spacing="-1">HF Market Data</text> | |
| 14 | − <text x="80" y="560" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="34" fill="#aeb6c4">Open high-frequency market data API · 1-minute to daily since 2010</text> | |
| 15 | − <text x="80" y="600" font-family="ui-monospace, Menlo, Consolas, monospace" font-size="24" fill="#5ee7a5">curl https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1min</text> | |
| 16 | −</svg> | |
added
hfmarketdata/web/scripts/make-brand-assets.mjs
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +// Renders public/og.png (1200×630), public/apple-touch-icon.png (180) and public/icon-{32,192,512}.png with Playwright | |
| 3 | +// from self-contained HTML (design tokens inlined, no network). Run after changing the brand: node scripts/make-brand-assets.mjs | |
| 4 | +import { mkdirSync, writeFileSync } from 'node:fs' | |
| 5 | +import { dirname, join, resolve } from 'node:path' | |
| 6 | +import { fileURLToPath } from 'node:url' | |
| 7 | +import { chromium } from 'playwright' | |
| 8 | + | |
| 9 | +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') | |
| 10 | +const PUB = join(ROOT, 'public') | |
| 11 | +mkdirSync(PUB, { recursive: true }) | |
| 12 | + | |
| 13 | +const MARK = (size, radius) => ` | |
| 14 | + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="${size}" height="${size}"> | |
| 15 | + <rect width="64" height="64" rx="${radius}" fill="#0a0c10"/> | |
| 16 | + <path d="M10 44 L20 30 L28 36 L38 20 L46 26 L54 14" fill="none" stroke="#34d399" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/> | |
| 17 | + <rect x="10" y="50" width="44" height="4" rx="2" fill="#34d399" opacity=".55"/> | |
| 18 | + </svg>` | |
| 19 | + | |
| 20 | +const SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif` | |
| 21 | +const MONO = `ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace` | |
| 22 | + | |
| 23 | +const OG = `<!doctype html><html><head><meta charset="utf-8"><style> | |
| 24 | + html, body { margin: 0; width: 1200px; height: 630px; background: #0a0c10; color: #e8ebf1; font-family: ${SANS}; -webkit-font-smoothing: antialiased; } | |
| 25 | + .wrap { position: relative; width: 1200px; height: 630px; overflow: hidden; background: radial-gradient(60% 70% at 15% 20%, rgba(52,211,153,.16), transparent 70%), radial-gradient(45% 60% at 85% 80%, rgba(96,165,250,.14), transparent 70%), #0a0c10; } | |
| 26 | + .grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); background-size: 60px 60px; } | |
| 27 | + .brand { position: absolute; left: 72px; top: 64px; display: flex; align-items: center; gap: 16px; font-weight: 650; font-size: 30px; letter-spacing: -.01em; } | |
| 28 | + .brand svg { border-radius: 14px; } | |
| 29 | + h1 { position: absolute; left: 72px; top: 170px; margin: 0; font-size: 66px; line-height: 1.08; letter-spacing: -.035em; font-weight: 700; max-width: 680px; } | |
| 30 | + h1 em { font-style: normal; color: #34d399; } | |
| 31 | + p { position: absolute; left: 72px; top: 400px; margin: 0; font-size: 26px; line-height: 1.4; color: #b4bcc8; max-width: 640px; } | |
| 32 | + .tags { position: absolute; left: 72px; bottom: 60px; display: flex; gap: 12px; } | |
| 33 | + .tag { font-family: ${MONO}; font-size: 20px; color: #e8ebf1; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14); padding: 8px 16px; border-radius: 999px; } | |
| 34 | + .code { position: absolute; right: 64px; top: 120px; width: 400px; background: #0c0f15; border: 1px solid #313847; border-radius: 16px; box-shadow: 0 30px 80px -30px rgba(0,0,0,.8); overflow: hidden; } | |
| 35 | + .code-head { padding: 12px 16px; border-bottom: 1px solid #232833; background: #10131a; font-family: ${MONO}; font-size: 15px; color: #8f98a8; display: flex; justify-content: space-between; } | |
| 36 | + .code pre { margin: 0; padding: 14px 18px; font-family: ${MONO}; font-size: 16px; line-height: 1.45; color: #d9dee8; } | |
| 37 | + .k { color: #7fd1ff; } .s { color: #9fe7c0; } .n { color: #ffcf7a; } .c { color: #7d8797; } | |
| 38 | + .chart { position: absolute; right: 64px; bottom: 52px; width: 400px; height: 90px; } | |
| 39 | +</style></head><body><div class="wrap"><div class="grid"></div> | |
| 40 | + <div class="brand">${MARK(56, 14)}HF Market Data</div> | |
| 41 | + <h1>Open high-frequency market data. <em>Free.</em></h1> | |
| 42 | + <p>1-minute to daily bars, futures contracts, options chains with Greeks, SEC fundamentals — one URL, no signup to start.</p> | |
| 43 | + <div class="tags"><span class="tag">JSON · CSV · Parquet</span><span class="tag">since 2010</span><span class="tag">MCP · Python · R · JS</span></div> | |
| 44 | + <div class="code"><div class="code-head"><span>GET /v1/bars/stock/AAPL</span><span>200 OK</span></div><pre>{ | |
| 45 | + <span class="k">"count"</span>: <span class="n">5</span>, | |
| 46 | + <span class="k">"data"</span>: [{ | |
| 47 | + <span class="k">"datetime"</span>: <span class="s">"2024-06-03"</span>, | |
| 48 | + <span class="k">"open"</span>: <span class="n">191.2434</span>, | |
| 49 | + <span class="k">"high"</span>: <span class="n">193.3154</span>, | |
| 50 | + <span class="k">"low"</span>: <span class="n">190.8666</span>, | |
| 51 | + <span class="k">"close"</span>: <span class="n">192.3637</span>, | |
| 52 | + <span class="k">"volume"</span>: <span class="n">50080500</span> | |
| 53 | + }, <span class="c">…</span>] | |
| 54 | +}</pre></div> | |
| 55 | + <svg class="chart" viewBox="0 0 400 110" preserveAspectRatio="none" fill="none"> | |
| 56 | + ${[[10,70,60,'#2fbf71'],[45,40,55,'#e5484d'],[80,50,75,'#2fbf71'],[115,30,50,'#e5484d'],[150,45,80,'#2fbf71'],[185,60,90,'#2fbf71'],[220,35,65,'#e5484d'],[255,50,85,'#2fbf71'],[290,65,95,'#2fbf71'],[325,40,70,'#e5484d'],[360,55,100,'#2fbf71']].map(([x,y1,y2,c]) => `<line x1="${x+9}" x2="${x+9}" y1="${110-y2-8}" y2="${110-y1+8}" stroke="${c}" stroke-width="2"/><rect x="${x}" y="${110-y2}" width="18" height="${y2-y1}" rx="2" fill="${c}"/>`).join('')} | |
| 57 | + </svg> | |
| 58 | +</div></body></html>` | |
| 59 | + | |
| 60 | +const ICON = (size, radius) => `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;background:transparent}svg{display:block}</style></head><body>${MARK(size, radius)}</body></html>` | |
| 61 | + | |
| 62 | +const browser = await chromium.launch() | |
| 63 | +const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 }) | |
| 64 | +await page.setContent(OG, { waitUntil: 'load' }) | |
| 65 | +writeFileSync(join(PUB, 'og.png'), await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: 1200, height: 630 } })) | |
| 66 | +for (const [file, size, radius] of [['apple-touch-icon.png', 180, 0], ['icon-32.png', 32, 7], ['icon-192.png', 192, 40], ['icon-512.png', 512, 108]]) { | |
| 67 | + await page.setViewportSize({ width: size, height: size }) | |
| 68 | + await page.setContent(ICON(size, radius), { waitUntil: 'load' }) | |
| 69 | + writeFileSync(join(PUB, file), await page.screenshot({ type: 'png', omitBackground: radius > 0, clip: { x: 0, y: 0, width: size, height: size } })) | |
| 70 | +} | |
| 71 | +await browser.close() | |
| 72 | +console.log('wrote public/og.png, apple-touch-icon.png, icon-32/192/512.png') | |
modified
hfmarketdata/web/scripts/prerender.mjs
+63 −35
@@ -1,16 +1,14 @@ | ||
| 1 | 1 | #!/usr/bin/env node |
| 2 | −// Post-build prerender of static HTML shells for the public entry routes. | |
| 2 | +// Post-build prerender of static HTML shells for every public entry route: home, limits, status, playground, | |
| 3 | +// integrations, legal pages, docs overview, reference index, errors, and EVERY guide (read from src/docs/guides.js + | |
| 4 | +// the guide's `meta` export). See README "Prerender" for the trade-off versus full SSR. | |
| 3 | 5 | // |
| 4 | −// Trade-off (see README "Prerender"): a full SSR of the React tree would need a second SSR build + a Node render step | |
| 5 | −// for every route (lazy routes, MDX, lightweight-charts, browser-only APIs), for pages that are mostly fetched live. | |
| 6 | −// Instead we emit, per route, a copy of dist/index.html with: route-specific <title>/<meta>/<link rel=canonical>, | |
| 7 | −// <link rel=modulepreload> for the route's chunk graph and <link rel=stylesheet> for its CSS (kills the lazy-route | |
| 8 | −// waterfall), and the above-the-fold content (header + h1 + lead) as static markup inside #root so first paint is | |
| 9 | −// meaningful before React mounts. React's createRoot() then replaces it with the live tree. | |
| 10 | −// | |
| 11 | −// Serving: vite preview / any static host resolves /limits → dist/limits/index.html. The FastAPI SPA fallback currently | |
| 12 | −// serves index.html for directories; it needs a one-line `candidate / "index.html"` check to pick these up. | |
| 13 | −import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' | |
| 6 | +// Per route we emit a copy of dist/index.html with route-specific <title>/<meta>/<link rel=canonical>, | |
| 7 | +// <link rel=modulepreload> for the route's chunk graph + <link rel=stylesheet> for its CSS (kills the lazy-route | |
| 8 | +// waterfall), and the above-the-fold content (header + h1 + lead) inside #root so first paint is meaningful before React | |
| 9 | +// mounts. Serving: vite preview / static hosts resolve /limits → dist/limits/index.html (and dist/limits.html); the | |
| 10 | +// FastAPI SPA fallback tries candidate, candidate.html and candidate/index.html. | |
| 11 | +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' | |
| 14 | 12 | import { dirname, join, resolve } from 'node:path' |
| 15 | 13 | import { fileURLToPath } from 'node:url' |
| 16 | 14 | |
@@ -20,29 +18,59 @@ const SITE = 'https://www.hfmarketdata.io' | ||
| 20 | 18 | |
| 21 | 19 | const NAV = [['/docs', 'Docs'], ['/playground', 'Playground'], ['/integrations', 'Integrations'], ['/limits', 'Limits'], ['/status', 'Status']] |
| 22 | 20 | |
| 23 | −const ROUTES = [ | |
| 21 | +const HOME_LEAD = 'Stocks, ETFs, futures — continuous series and every individual contract — crypto, indices and FX, plus full options chains with Greeks and point-in-time SEC fundamentals. One base URL, JSON / CSV / Parquet, docs generated from the spec, a playground, and limits that only grow.' | |
| 22 | + | |
| 23 | +const STATIC_ROUTES = [ | |
| 24 | 24 | { path: '/', entry: 'src/pages/home/Home.jsx', title: 'HF Market Data — Open High-Frequency Market Data API', |
| 25 | 25 | description: 'Free open API for high-frequency historical market data: stocks, ETFs, futures (continuous and individual contracts), crypto, indices, FX from 1-minute to daily, options chains with Greeks and SEC EDGAR fundamentals since 2010.', |
| 26 | − h1: 'Open high-frequency market data. 1-minute to daily, since 2010.', | |
| 27 | − lead: 'Stocks, ETFs, futures — continuous series and every individual contract — crypto, indices and FX, plus full options chains with Greeks and point-in-time SEC fundamentals. One base URL, JSON / CSV / Parquet, generated docs, a playground, and limits that only grow.', cls: 'hero' }, | |
| 28 | − // entry falls back to the temporary src/pages/pricing shim until App.jsx imports Limits.jsx directly | |
| 29 | − { path: '/limits', entry: ['src/pages/limits/Limits.jsx', 'src/pages/pricing/Pricing.jsx'], title: 'Access & limits — free for everyone · HF Market Data', | |
| 26 | + h1: 'Open high-frequency market data. 1-minute to daily, since 2010.', lead: HOME_LEAD, cls: 'hero' }, | |
| 27 | + { path: '/limits', entry: 'src/pages/limits/Limits.jsx', title: 'Access & limits — free for everyone · HF Market Data', | |
| 30 | 28 | description: 'HF Market Data is free. Keyless access is very limited, a free account gives much higher limits, and higher limits are granted on request by e-mail.', |
| 31 | 29 | h1: 'Free for everyone.', lead: 'There is nothing to buy. Keyless access is deliberately small so you can try things without signing up; a free account raises the limits a lot; and if you need more, you simply ask.' }, |
| 32 | − { path: '/docs', entry: 'src/pages/docs/Docs.jsx', title: 'Documentation · HF Market Data', | |
| 33 | − description: 'HF Market Data documentation: quickstart, authentication, rate limits, futures contracts, options, fundamentals, and the full API reference generated from OpenAPI.', | |
| 34 | − h1: 'Documentation', lead: 'Historical market data from 1-minute to daily since 2010 — stocks, ETFs, futures, crypto, indices, FX — plus options chains with Greeks and SEC EDGAR fundamentals. One base URL, JSON / CSV / Parquet, no signup to start.' }, | |
| 35 | − { path: '/docs/quickstart', entry: 'src/pages/docs/Docs.jsx', title: 'Quickstart · HF Market Data', | |
| 36 | − description: 'Your first HF Market Data request in 30 seconds — no signup, no key.', h1: 'Quickstart', | |
| 37 | − lead: 'You can pull real data right now, with nothing but a URL. No account, no key, no SDK.' }, | |
| 38 | 30 | { path: '/status', entry: 'src/pages/status/Status.jsx', title: 'Status · HF Market Data', |
| 39 | 31 | description: 'Live health and dataset inventory of the HF Market Data API.', h1: 'API health & dataset inventory', |
| 40 | 32 | lead: 'Everything on this page is fetched live from the API when you open it — nothing is cached or hand-maintained.' }, |
| 33 | + { path: '/playground', entry: 'src/pages/playground/Playground.jsx', title: 'Playground · HF Market Data', | |
| 34 | + description: 'Run any HF Market Data request in the browser: pick an endpoint, edit parameters, inspect JSON, table or chart, copy the code.', h1: 'Playground', | |
| 35 | + lead: 'Pick an endpoint, edit the parameters, run it against the live API and inspect the result as JSON, table or chart — then copy the code.' }, | |
| 36 | + { path: '/integrations', entry: 'src/pages/integrations/Integrations.jsx', title: 'Integrations — MCP server, Claude Code, Cursor, Codex, skills · HF Market Data', | |
| 37 | + description: 'Use HF Market Data from Claude Code, Cursor, Codex and any MCP client: one MCP server with 14 read-only tools, plus a skills pack with ready-to-run Python.', h1: 'Market data where your agent already works.', | |
| 38 | + lead: 'One MCP server gives Claude Code, Cursor, Codex and Claude Desktop 14 read-only tools over the whole HF Market Data API. Plus five Claude skills with ready-to-run Python.' }, | |
| 39 | + { path: '/terms', entry: 'src/pages/legal/Legal.jsx', title: 'Terms of use · HF Market Data', description: 'Terms of use of the HF Market Data API and website — a free platform, offered as is, with reasonable-use limits.', h1: 'Terms of use', lead: 'HF Market Data is a free, open API and website for historical market data. Using it means you accept these short terms.' }, | |
| 40 | + { path: '/privacy', entry: 'src/pages/legal/Legal.jsx', title: 'Privacy · HF Market Data', description: 'What HF Market Data stores about you (account e-mail, hashed keys, request counters), what it does not (no third-party tracking), and how to delete it.', h1: 'Privacy', lead: 'An e-mail address if you create an account, hashed API keys, request counters — and nothing for advertising.' }, | |
| 41 | + { path: '/data-license', entry: 'src/pages/legal/Legal.jsx', title: 'Data license & attribution · HF Market Data', description: 'Where the data comes from (FirstRate Data, SEC EDGAR), what you may do with it, and how to attribute it.', h1: 'Data license & attribution', lead: 'Market data from FirstRate Data, fundamentals from SEC EDGAR — what you may do with each, and how to give credit.' }, | |
| 42 | + { path: '/docs', entry: 'src/pages/docs/Docs.jsx', title: 'Documentation · HF Market Data', | |
| 43 | + description: 'HF Market Data documentation: quickstart, authentication, rate limits, futures contracts, options, fundamentals, and the full API reference generated from OpenAPI.', | |
| 44 | + h1: 'Documentation', lead: 'Historical market data from 1-minute to daily since 2010 — stocks, ETFs, futures, crypto, indices, FX — plus options chains with Greeks and SEC EDGAR fundamentals. One base URL, JSON / CSV / Parquet, no signup to start.' }, | |
| 45 | + { path: '/docs/reference', entry: 'src/pages/docs/Docs.jsx', title: 'API reference · HF Market Data', | |
| 46 | + description: 'Every endpoint of the HF Market Data API, generated from the OpenAPI 3.1 specification.', h1: 'API reference', | |
| 47 | + lead: 'Every endpoint, generated from openapi.json. Each page shows parameters, real examples, error codes and a one-click Try it.' }, | |
| 48 | + { path: '/docs/errors', entry: 'src/pages/docs/Docs.jsx', title: 'Error codes · HF Market Data', | |
| 49 | + description: 'Every error code returned by the HF Market Data API, its HTTP status, meaning and how to fix it.', h1: 'Errors', | |
| 50 | + lead: 'Every error response — on every endpoint, v1 and v2 — has the same JSON envelope. The error.docs URL points to the matching anchor on this page.' }, | |
| 41 | 51 | ] |
| 42 | 52 | |
| 43 | 53 | const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"') |
| 44 | 54 | |
| 45 | −function chunkGraph(manifest, entry) { | |
| 55 | +/** Guides from src/docs/guides.js (slug/title/file/summary) + the MDX `meta.description` when present. */ | |
| 56 | +function guideRoutes() { | |
| 57 | + const src = readFileSync(join(ROOT, 'src/docs/guides.js'), 'utf8') | |
| 58 | + const re = /\{\s*slug:\s*'([^']+)',\s*title:\s*'((?:\\'|[^'])+)',\s*section:\s*'[^']+',\s*file:\s*'([^']+)',\s*summary:\s*'((?:\\'|[^'])+)'/g | |
| 59 | + const out = [] | |
| 60 | + for (const m of src.matchAll(re)) { | |
| 61 | + const [, slug, title, file, summary] = m.map(x => (x || '').replace(/\\'/g, "'")) | |
| 62 | + const mdxPath = file === '__changelog' ? join(ROOT, 'content/changelog.mdx') : join(ROOT, `content/guides/${file}.mdx`) | |
| 63 | + let description = summary | |
| 64 | + if (existsSync(mdxPath)) { | |
| 65 | + const meta = /export const meta = \{[^}]*description:\s*'((?:\\'|[^'])+)'/.exec(readFileSync(mdxPath, 'utf8')) | |
| 66 | + if (meta) description = meta[1].replace(/\\'/g, "'") | |
| 67 | + } | |
| 68 | + out.push({ path: `/docs/${slug}`, entry: 'src/pages/docs/Docs.jsx', title: `${title} · HF Market Data`, description, h1: title, lead: description, guide: file }) | |
| 69 | + } | |
| 70 | + return out | |
| 71 | +} | |
| 72 | + | |
| 73 | +function chunkGraph(manifest, entry, extra = []) { | |
| 46 | 74 | const files = new Set(), css = new Set() |
| 47 | 75 | const walk = key => { |
| 48 | 76 | const m = manifest[key] |
@@ -53,13 +81,15 @@ function chunkGraph(manifest, entry) { | ||
| 53 | 81 | } |
| 54 | 82 | const entries = Array.isArray(entry) ? entry : [entry] |
| 55 | 83 | walk(entries.find(e => manifest[e]) || entries[0]) |
| 84 | + for (const e of extra) walk(e) | |
| 56 | 85 | return { files: [...files], css: [...css] } |
| 57 | 86 | } |
| 58 | 87 | |
| 59 | 88 | function shell(route) { |
| 60 | 89 | const nav = NAV.map(([to, label]) => `<a href="${to}"${route.path.startsWith(to) ? ' class="active"' : ''}>${label}</a>`).join('') |
| 90 | + const hero = route.cls === 'hero' | |
| 61 | 91 | return `<div class="shell"><header class="topbar"><a class="brand" href="/"><span class="brand-mark">HF</span><span class="brand-name">Market Data</span></a><nav class="topnav" aria-label="Main">${nav}</nav></header>` + |
| 62 | − `<main class="${route.cls === 'hero' ? 'home' : 'page'}"><section class="${route.cls === 'hero' ? 'hero' : 'limits-head'}" style="padding-top:${route.cls === 'hero' ? '72px' : '0'}"><div class="hero-inner"><h1>${esc(route.h1)}</h1><p class="lead">${esc(route.lead)}</p></div></section></main></div>` | |
| 92 | + `<main class="${hero ? 'home' : 'page'}"><section class="${hero ? 'hero' : 'prerender-head'}"><div class="hero-inner"><h1>${esc(route.h1)}</h1><p class="lead">${esc(route.lead)}</p></div></section></main></div>` | |
| 63 | 93 | } |
| 64 | 94 | |
| 65 | 95 | function main() { |
@@ -68,13 +98,15 @@ function main() { | ||
| 68 | 98 | const base = readFileSync(indexPath, 'utf8') |
| 69 | 99 | const manifestPath = join(DIST, '.vite', 'manifest.json') |
| 70 | 100 | const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {} |
| 101 | + const routes = [...STATIC_ROUTES, ...guideRoutes()] | |
| 71 | 102 | let count = 0 |
| 72 | − for (const route of ROUTES) { | |
| 73 | − const { files, css } = chunkGraph(manifest, route.entry) | |
| 103 | + for (const route of routes) { | |
| 104 | + const extra = route.guide ? [route.guide === '__changelog' ? 'content/changelog.mdx' : `content/guides/${route.guide}.mdx`] : [] | |
| 105 | + const { files, css } = chunkGraph(manifest, route.entry, extra) | |
| 74 | 106 | const alreadyLinked = f => base.includes(`/${f}`) |
| 75 | 107 | const preload = files.filter(f => !alreadyLinked(f)).map(f => `<link rel="modulepreload" crossorigin href="/${f}">`).join('') |
| 76 | 108 | const styles = css.filter(f => !alreadyLinked(f)).map(f => `<link rel="stylesheet" href="/${f}">`).join('') |
| 77 | − let html = base | |
| 109 | + const html = base | |
| 78 | 110 | .replace(/<title>[^<]*<\/title>/, `<title>${esc(route.title)}</title>`) |
| 79 | 111 | .replace(/<meta name="description" content="[^"]*"\s*\/?>/, `<meta name="description" content="${esc(route.description)}" />`) |
| 80 | 112 | .replace(/<meta property="og:title" content="[^"]*"\s*\/?>/, `<meta property="og:title" content="${esc(route.title)}" />`) |
@@ -82,18 +114,14 @@ function main() { | ||
| 82 | 114 | .replace(/<link rel="canonical" href="[^"]*"\s*\/?>/, `<link rel="canonical" href="${SITE}${route.path === '/' ? '/' : route.path}" />`) |
| 83 | 115 | .replace('</head>', `${styles}${preload}<meta property="og:url" content="${SITE}${route.path}" /></head>`) |
| 84 | 116 | .replace('<div id="root"></div>', `<div id="root">${shell(route)}</div>`) |
| 85 | − // Two file forms so any static server picks the shell up: <route>/index.html (nginx try_files, Netlify, S3) | |
| 86 | − // and <route>.html (vite preview's html fallback, GitHub Pages-style hosts). | |
| 117 | + // Two file forms so any static server picks the shell up: <route>/index.html and <route>.html. | |
| 87 | 118 | const outs = route.path === '/' ? [indexPath] : [join(DIST, route.path.replace(/^\//, ''), 'index.html'), join(DIST, `${route.path.replace(/^\//, '')}.html`)] |
| 88 | 119 | for (const out of outs) { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, html) } |
| 89 | 120 | count++ |
| 90 | − console.log(`prerendered ${route.path} → ${outs.map(o => o.replace(DIST + '/', '')).join(', ')} (${files.length} chunks preloaded, ${css.length} css)`) | |
| 91 | 121 | } |
| 92 | − // sitemap for the static routes + guides known at build time | |
| 93 | − const guides = Object.keys(manifest).filter(k => k.startsWith('content/guides/')).map(k => k.replace('content/guides/', '').replace(/\.mdx$/, '')) | |
| 94 | − const urls = [...ROUTES.map(r => r.path), '/docs/reference', '/docs/errors', '/docs/changelog', ...guides.map(g => `/docs/${g.replace(/^recipe-/, 'recipes/').replace(/^fundamentals-/, 'fundamentals/')}`)] | |
| 95 | − writeFileSync(join(DIST, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${[...new Set(urls)].map(u => ` <url><loc>${SITE}${u}</loc></url>`).join('\n')}\n</urlset>\n`) | |
| 96 | − console.log(`${count} shells + sitemap.xml (${urls.length} urls)`) | |
| 122 | + const urls = [...new Set(routes.map(r => r.path))] | |
| 123 | + writeFileSync(join(DIST, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map(u => ` <url><loc>${SITE}${u}</loc></url>`).join('\n')}\n</urlset>\n`) | |
| 124 | + console.log(`prerendered ${count} shells (${routes.filter(r => r.guide).length} guides) + sitemap.xml (${urls.length} urls)`) | |
| 97 | 125 | } |
| 98 | 126 | |
| 99 | 127 | main() |
modified
hfmarketdata/web/src/App.jsx
+9 −2
@@ -1,5 +1,7 @@ | ||
| 1 | −// Route table — one lazy chunk per area. Owners: web-core (home, limits, docs, status, layout), | |
| 1 | +// Route table — one lazy chunk per area. Owners: web-core (home, limits, docs, status, legal, layout, 404), | |
| 2 | 2 | // web-app (playground, auth, dashboard, admin), mcp-skills (integrations). |
| 3 | +// Public routes (mirrored by the server's SPA whitelist): / /docs/* /playground /integrations/* /limits /pricing | |
| 4 | +// /status /terms /privacy /data-license /changelog (→ /docs/changelog) + auth/dashboard/admin. | |
| 3 | 5 | import React, { Suspense, lazy } from 'react' |
| 4 | 6 | import { Navigate, Route, Routes } from 'react-router-dom' |
| 5 | 7 | import Layout from './app/Layout.jsx' |
@@ -10,10 +12,11 @@ const Playground = lazy(() => import('./pages/playground/Playground.jsx')) | ||
| 10 | 12 | const Integrations = lazy(() => import('./pages/integrations/Integrations.jsx')) |
| 11 | 13 | const Limits = lazy(() => import('./pages/limits/Limits.jsx')) |
| 12 | 14 | const Status = lazy(() => import('./pages/status/Status.jsx')) |
| 15 | +const Legal = lazy(() => import('./pages/legal/Legal.jsx')) | |
| 16 | +const NotFound = lazy(() => import('./pages/NotFound.jsx')) | |
| 13 | 17 | const Auth = lazy(() => import('./pages/auth/Auth.jsx')) |
| 14 | 18 | const Dashboard = lazy(() => import('./pages/dashboard/Dashboard.jsx')) |
| 15 | 19 | const Admin = lazy(() => import('./pages/admin/Admin.jsx')) |
| 16 | −const NotFound = () => <main className="page narrow"><h1>404</h1><p className="muted">This page does not exist.</p></main> | |
| 17 | 20 | |
| 18 | 21 | export default function App() { |
| 19 | 22 | return ( |
@@ -27,6 +30,10 @@ export default function App() { | ||
| 27 | 30 | <Route path="/limits" element={<Limits />} /> |
| 28 | 31 | <Route path="/pricing" element={<Navigate to="/limits" replace />} /> |
| 29 | 32 | <Route path="/status" element={<Status />} /> |
| 33 | + <Route path="/changelog" element={<Navigate to="/docs/changelog" replace />} /> | |
| 34 | + <Route path="/terms" element={<Legal page="terms" />} /> | |
| 35 | + <Route path="/privacy" element={<Legal page="privacy" />} /> | |
| 36 | + <Route path="/data-license" element={<Legal page="data-license" />} /> | |
| 30 | 37 | <Route path="/signin" element={<Auth mode="signin" />} /> |
| 31 | 38 | <Route path="/signup" element={<Auth mode="signup" />} /> |
| 32 | 39 | <Route path="/verify" element={<Auth mode="verify" />} /> |
modified
hfmarketdata/web/src/app/Layout.jsx
+24 −8
@@ -1,4 +1,5 @@ | ||
| 1 | 1 | // App shell: top bar (nav · search · theme · session), mobile drawer, footer. Owner: web-core. |
| 2 | +// Header is 56 px; every nav target is ≥ 36 px on desktop and ≥ 44 px on touch viewports (theme.css --tap). | |
| 2 | 3 | import React, { Suspense, lazy, useCallback, useEffect, useState } from 'react' |
| 3 | 4 | import { Link, NavLink, useLocation } from 'react-router-dom' |
| 4 | 5 | import { useAuth } from './auth.jsx' |
@@ -28,6 +29,7 @@ export const NAV = [ | ||
| 28 | 29 | ] |
| 29 | 30 | |
| 30 | 31 | export const REPO_URL = 'https://git.spboucher.ai/hfmarketdata.git' |
| 32 | +export const AUTHOR = 'Simon-Pierre Boucher' | |
| 31 | 33 | |
| 32 | 34 | const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) |
| 33 | 35 | |
@@ -36,15 +38,24 @@ export default function Layout({ children }) { | ||
| 36 | 38 | const { theme, toggle } = useTheme() |
| 37 | 39 | const [drawer, setDrawer] = useState(false) |
| 38 | 40 | const [searchOpen, setSearchOpen] = useState(false) |
| 41 | + const [searchSeed, setSearchSeed] = useState('') | |
| 39 | 42 | const location = useLocation() |
| 40 | − const openSearch = useCallback(() => setSearchOpen(true), []) | |
| 43 | + const openSearch = useCallback(() => { setSearchSeed(''); setSearchOpen(true) }, []) | |
| 41 | 44 | useSearchShortcut(openSearch) |
| 45 | + // Other pages (404) can open the search dialog with a query: window.dispatchEvent(new CustomEvent('hfmd:search', { detail })) | |
| 46 | + useEffect(() => { | |
| 47 | + const on = e => { setSearchSeed(typeof e.detail === 'string' ? e.detail : ''); setSearchOpen(true) } | |
| 48 | + window.addEventListener('hfmd:search', on) | |
| 49 | + return () => window.removeEventListener('hfmd:search', on) | |
| 50 | + }, []) | |
| 42 | 51 | useEffect(() => { setDrawer(false) }, [location.pathname]) |
| 43 | 52 | useEffect(() => { |
| 44 | − if (!drawer) return | |
| 53 | + if (!drawer) return undefined | |
| 45 | 54 | const onKey = e => { if (e.key === 'Escape') setDrawer(false) } |
| 46 | 55 | window.addEventListener('keydown', onKey) |
| 47 | − return () => window.removeEventListener('keydown', onKey) | |
| 56 | + const prev = document.body.style.overflow | |
| 57 | + document.body.style.overflow = 'hidden' | |
| 58 | + return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = prev } | |
| 48 | 59 | }, [drawer]) |
| 49 | 60 | // Scroll to top on route change (hash links handled by the docs pages). |
| 50 | 61 | useEffect(() => { if (!location.hash) window.scrollTo(0, 0) }, [location.pathname, location.hash]) |
@@ -87,6 +98,7 @@ export default function Layout({ children }) { | ||
| 87 | 98 | </div> |
| 88 | 99 | <NavLink to="/" end className="navlink">Home</NavLink> |
| 89 | 100 | {NAV.map(([to, label]) => <NavLink key={to} to={to} className="navlink">{label}</NavLink>)} |
| 101 | + <NavLink to="/docs/changelog" className="navlink">Changelog</NavLink> | |
| 90 | 102 | <div className="drawer-foot"> |
| 91 | 103 | {user ? <Link to="/dashboard" className="btn">Dashboard</Link> : <><Link to="/signin" className="btn">Sign in</Link><Link to="/signup" className="btn btn-primary">Create free account</Link></>} |
| 92 | 104 | </div> |
@@ -100,16 +112,20 @@ export default function Layout({ children }) { | ||
| 100 | 112 | <div className="footer-inner"> |
| 101 | 113 | <div> |
| 102 | 114 | <div className="brand" style={{ marginBottom: 10 }}><span className="brand-mark" aria-hidden="true">HF</span>Market Data</div> |
| 103 | − <p className="muted" style={{ maxWidth: 320 }}>Open high-frequency market data API. 1-minute to daily bars since 2010, futures contracts, options chains with Greeks, SEC EDGAR fundamentals.</p> | |
| 115 | + <p className="muted" style={{ maxWidth: 320 }}>Open high-frequency market data API. 1-minute to daily bars, futures contracts, options chains with Greeks, SEC EDGAR fundamentals. Free for everyone.</p> | |
| 104 | 116 | <p className="muted small">Base URL <code>{PUBLIC_BASE}</code></p> |
| 105 | 117 | </div> |
| 106 | 118 | <div> |
| 107 | 119 | <h4>Product</h4> |
| 108 | − <ul><li><Link to="/docs">Documentation</Link></li><li><Link to="/docs/reference">API reference</Link></li><li><Link to="/playground">Playground</Link></li><li><Link to="/integrations">Integrations</Link></li><li><Link to="/limits">Access & limits</Link></li></ul> | |
| 120 | + <ul><li><Link to="/docs">Docs</Link></li><li><Link to="/docs/reference">API reference</Link></li><li><Link to="/playground">Playground</Link></li><li><Link to="/integrations">Integrations</Link></li><li><Link to="/limits">Limits</Link></li></ul> | |
| 109 | 121 | </div> |
| 110 | 122 | <div> |
| 111 | 123 | <h4>Resources</h4> |
| 112 | − <ul><li><Link to="/docs/quickstart">Quickstart</Link></li><li><Link to="/docs/changelog">Changelog</Link></li><li><Link to="/docs/errors">Error codes</Link></li><li><Link to="/status">Status</Link></li><li><a href={`${PUBLIC_BASE}/openapi.json`}>openapi.json</a></li></ul> | |
| 124 | + <ul><li><Link to="/docs/quickstart">Quickstart</Link></li><li><Link to="/status">Status</Link></li><li><Link to="/docs/changelog">Changelog</Link></li><li><Link to="/docs/errors">Error codes</Link></li><li><a href={`${PUBLIC_BASE}/openapi.json`}>openapi.json</a></li></ul> | |
| 125 | + </div> | |
| 126 | + <div> | |
| 127 | + <h4>Legal</h4> | |
| 128 | + <ul><li><Link to="/terms">Terms</Link></li><li><Link to="/privacy">Privacy</Link></li><li><Link to="/data-license">Data license & attribution</Link></li></ul> | |
| 113 | 129 | </div> |
| 114 | 130 | <div> |
| 115 | 131 | <h4>Contact</h4> |
@@ -117,14 +133,14 @@ export default function Layout({ children }) { | ||
| 117 | 133 | </div> |
| 118 | 134 | </div> |
| 119 | 135 | <div className="footer-bottom"> |
| 120 | − <span>© {new Date().getFullYear()} HF Market Data · Simon-Pierre Boucher</span> | |
| 136 | + <span>© {new Date().getFullYear()} HF Market Data · Built by {AUTHOR}</span> | |
| 121 | 137 | <span>Data: FirstRate Data · SEC EDGAR · UTC / ISO 8601 everywhere</span> |
| 122 | 138 | </div> |
| 123 | 139 | </footer> |
| 124 | 140 | |
| 125 | 141 | {searchOpen && ( |
| 126 | 142 | <Suspense fallback={null}> |
| 127 | − <SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} /> | |
| 143 | + <SearchDialog open={searchOpen} initialQuery={searchSeed} onClose={() => setSearchOpen(false)} /> | |
| 128 | 144 | </Suspense> |
| 129 | 145 | )} |
| 130 | 146 | </div> |
modified
hfmarketdata/web/src/app/theme.css
+188 −82
@@ -1,146 +1,252 @@ | ||
| 1 | −/* HF Market Data design tokens + shell. Dark by default, light via [data-theme="light"] (persisted in localStorage). | |
| 2 | − System font stacks only — no external font requests (LCP budget < 1 s). Owner: web-core. */ | |
| 1 | +/* HF Market Data design system — tokens, base, shell, utilities. Owner: web-core. | |
| 2 | + Dark by default, light via [data-theme="light"] (persisted; index.html applies it before first paint). | |
| 3 | + Sans = system stack. Mono = JetBrains Mono (self-hosted, latin subset, variable weight) — the "data platform" signal. | |
| 4 | + Breakpoints (CSS cannot read custom properties in @media): 640 / 960 / 1200 px. */ | |
| 5 | + | |
| 6 | +@font-face { | |
| 7 | + font-family: "JetBrains Mono"; | |
| 8 | + font-style: normal; | |
| 9 | + font-weight: 100 800; | |
| 10 | + font-display: swap; | |
| 11 | + src: url("/fonts/jetbrains-mono-latin.woff2") format("woff2"); | |
| 12 | + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; | |
| 13 | +} | |
| 14 | + | |
| 3 | 15 | :root { |
| 4 | 16 | color-scheme: dark; |
| 17 | + /* surfaces */ | |
| 5 | 18 | --bg: #0a0c10; --bg-1: #10131a; --bg-2: #161a22; --bg-3: #1d222c; |
| 6 | 19 | --line: #232833; --line-2: #313847; --line-3: #414a5c; |
| 7 | − --fg: #e8ebf1; --fg-1: #aeb6c4; --fg-2: #7d8697; --fg-3: #5a6373; | |
| 8 | − --accent: #5ee7a5; --accent-ink: #04150c; --accent-soft: rgba(94, 231, 165, 0.12); | |
| 9 | − --accent-2: #4db8ff; --accent-2-soft: rgba(77, 184, 255, 0.12); | |
| 10 | − --danger: #ff6b6b; --danger-soft: rgba(255, 107, 107, 0.12); | |
| 11 | − --warn: #ffc857; --warn-soft: rgba(255, 200, 87, 0.12); | |
| 12 | − --get: #4db8ff; --post: #5ee7a5; --delete: #ff6b6b; --put: #ffc857; --patch: #c792ea; | |
| 13 | − --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; | |
| 20 | + /* text — every --fg-* is ≥ 4.5:1 on --bg, --bg-1 and --bg-2; --fg-muted-deco is decorative only */ | |
| 21 | + --fg: #e8ebf1; --fg-1: #b4bcc8; --fg-2: #8f98a8; --fg-3: #7d8797; --fg-muted-deco: #5a6373; | |
| 22 | + /* accent + secondary */ | |
| 23 | + --accent: #34d399; --accent-ink: #052e1c; --accent-soft: rgba(52, 211, 153, 0.13); --accent-strong: #6ee7b7; | |
| 24 | + --accent-2: #60a5fa; --accent-2-soft: rgba(96, 165, 250, 0.13); | |
| 25 | + /* market semantics */ | |
| 26 | + --up: #2fbf71; --down: #e5484d; --neutral: #8b95a7; | |
| 27 | + /* states */ | |
| 28 | + --warn: #f5b544; --warn-soft: rgba(245, 181, 68, 0.13); | |
| 29 | + --danger: #e5484d; --danger-soft: rgba(229, 72, 77, 0.13); | |
| 30 | + --info: #60a5fa; --info-soft: rgba(96, 165, 250, 0.13); | |
| 31 | + --success: #34d399; --success-soft: rgba(52, 211, 153, 0.13); | |
| 32 | + /* HTTP verbs */ | |
| 33 | + --get: #60a5fa; --post: #34d399; --delete: #e5484d; --put: #f5b544; --patch: #c084fc; | |
| 34 | + /* type */ | |
| 14 | 35 | --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
| 15 | − --radius: 8px; --radius-lg: 14px; --max: 1240px; | |
| 16 | − --shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 40px -12px rgba(0, 0, 0, .55); | |
| 17 | − --topbar-h: 56px; | |
| 18 | − --code-bg: #0c0f15; --code-fg: #d9dee8; --code-comment: #6b7688; --code-key: #7fd1ff; --code-string: #9fe7c0; --code-number: #ffcf7a; --code-keyword: #d6a6ff; --code-punct: #8d97a8; --code-func: #9fd0ff; | |
| 36 | + --mono: "JetBrains Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; | |
| 37 | + --fs-0: 12px; --fs-1: 13px; --fs-2: 14px; --fs-3: 15px; --fs-4: 17px; --fs-5: 20px; --fs-6: 24px; --fs-7: 32px; --fs-8: 44px; | |
| 38 | + --lh-tight: 1.15; --lh-snug: 1.3; --lh-body: 1.55; --lh-prose: 1.7; | |
| 39 | + /* spacing 4·n */ | |
| 40 | + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --sp-7: 28px; --sp-8: 32px; --sp-9: 36px; --sp-10: 40px; | |
| 41 | + /* radii */ | |
| 42 | + --r-sm: 6px; --r-md: 10px; --r-lg: 16px; | |
| 43 | + --radius: var(--r-md); --radius-lg: var(--r-lg); /* legacy aliases (dashboard.css, auth.css) */ | |
| 44 | + /* shadows */ | |
| 45 | + --shadow-1: 0 1px 2px rgba(0, 0, 0, .35); | |
| 46 | + --shadow-2: 0 1px 2px rgba(0, 0, 0, .3), 0 16px 48px -16px rgba(0, 0, 0, .6); | |
| 47 | + --shadow: var(--shadow-2); | |
| 48 | + /* layers */ | |
| 49 | + --z-sticky: 10; --z-drawer: 60; --z-modal: 80; | |
| 50 | + /* layout */ | |
| 51 | + --max: 1240px; --topbar-h: 56px; | |
| 52 | + /* code */ | |
| 53 | + --code-bg: #0c0f15; --code-fg: #d9dee8; --code-comment: #7d8797; --code-key: #7fd1ff; --code-string: #9fe7c0; --code-number: #ffcf7a; --code-keyword: #d6a6ff; --code-punct: #98a2b3; --code-func: #9fd0ff; | |
| 54 | + /* controls */ | |
| 55 | + --tap: 36px; --tap-mobile: 44px; | |
| 19 | 56 | } |
| 20 | 57 | [data-theme="light"] { |
| 21 | 58 | color-scheme: light; |
| 22 | 59 | --bg: #ffffff; --bg-1: #f6f7f9; --bg-2: #eef0f4; --bg-3: #e4e7ed; |
| 23 | 60 | --line: #e2e5eb; --line-2: #cfd4dd; --line-3: #b4bcc9; |
| 24 | − --fg: #12161d; --fg-1: #3b4452; --fg-2: #66707f; --fg-3: #8b94a3; | |
| 25 | − --accent: #0d8a55; --accent-ink: #ffffff; --accent-soft: rgba(13, 138, 85, 0.10); | |
| 26 | − --accent-2: #0b6fb8; --accent-2-soft: rgba(11, 111, 184, 0.10); | |
| 27 | − --danger: #d23b3b; --danger-soft: rgba(210, 59, 59, 0.10); | |
| 28 | − --warn: #b7791f; --warn-soft: rgba(183, 121, 31, 0.12); | |
| 29 | − --get: #0b6fb8; --post: #0d8a55; --delete: #d23b3b; --put: #b7791f; --patch: #7c3aed; | |
| 30 | − --shadow: 0 1px 2px rgba(20, 24, 31, .06), 0 12px 40px -16px rgba(20, 24, 31, .22); | |
| 31 | − --code-bg: #f4f6f9; --code-fg: #1d2430; --code-comment: #7a8494; --code-key: #0b5ea8; --code-string: #0d7a4a; --code-number: #a35a00; --code-keyword: #7c3aed; --code-punct: #5d6675; --code-func: #0b5ea8; | |
| 61 | + --fg: #12161d; --fg-1: #3b4452; --fg-2: #4f5a6b; --fg-3: #5f6b7d; --fg-muted-deco: #98a2b3; | |
| 62 | + --accent: #0f766e; --accent-ink: #ffffff; --accent-soft: rgba(15, 118, 110, 0.10); --accent-strong: #0d9488; | |
| 63 | + --accent-2: #1d4ed8; --accent-2-soft: rgba(29, 78, 216, 0.10); | |
| 64 | + --up: #15803d; --down: #d92d20; --neutral: #64708a; | |
| 65 | + --warn: #b45309; --warn-soft: rgba(180, 83, 9, 0.10); | |
| 66 | + --danger: #d92d20; --danger-soft: rgba(217, 45, 32, 0.10); | |
| 67 | + --info: #1d4ed8; --info-soft: rgba(29, 78, 216, 0.10); | |
| 68 | + --success: #0f766e; --success-soft: rgba(15, 118, 110, 0.10); | |
| 69 | + --get: #1d4ed8; --post: #0f766e; --delete: #d92d20; --put: #b45309; --patch: #7c3aed; | |
| 70 | + --shadow-1: 0 1px 2px rgba(20, 24, 31, .06); | |
| 71 | + --shadow-2: 0 1px 2px rgba(20, 24, 31, .06), 0 16px 48px -20px rgba(20, 24, 31, .25); | |
| 72 | + --code-bg: #f4f6f9; --code-fg: #1d2430; --code-comment: #5f6b7d; --code-key: #0b5ea8; --code-string: #0b6b45; --code-number: #9a4d00; --code-keyword: #6d28d9; --code-punct: #4f5a6b; --code-func: #0b5ea8; | |
| 32 | 73 | } |
| 33 | 74 | |
| 34 | −/* ---- base ---- */ | |
| 75 | +/* ---- base ---------------------------------------------------------------------------------- */ | |
| 35 | 76 | *, *::before, *::after { box-sizing: border-box; } |
| 36 | −html { font-family: var(--sans); background: var(--bg); color: var(--fg); -webkit-font-smoothing: antialiased; font-size: 15px; line-height: 1.55; text-rendering: optimizeLegibility; scroll-padding-top: calc(var(--topbar-h) + 16px); } | |
| 37 | −body { margin: 0; min-height: 100dvh; } | |
| 38 | −h1, h2, h3, h4 { line-height: 1.2; letter-spacing: -0.015em; margin: 0 0 0.5em; font-weight: 650; } | |
| 39 | −h1 { font-size: clamp(28px, 4vw, 40px); } | |
| 40 | −h2 { font-size: clamp(22px, 2.6vw, 28px); } | |
| 41 | −h3 { font-size: 18px; } | |
| 42 | −h4 { font-size: 15px; } | |
| 77 | +html { font-family: var(--sans); background: var(--bg); color: var(--fg); -webkit-font-smoothing: antialiased; font-size: var(--fs-3); line-height: var(--lh-body); text-rendering: optimizeLegibility; scroll-padding-top: calc(var(--topbar-h) + 16px); overflow-x: clip; } | |
| 78 | +body { margin: 0; min-height: 100dvh; overflow-x: clip; } | |
| 79 | +h1, h2, h3, h4 { line-height: var(--lh-tight); letter-spacing: -0.015em; margin: 0 0 0.5em; font-weight: 650; text-wrap: balance; } | |
| 80 | +h1 { font-size: clamp(var(--fs-7), 4vw, var(--fs-8)); letter-spacing: -0.025em; } | |
| 81 | +h2 { font-size: clamp(var(--fs-6), 2.6vw, var(--fs-7)); } | |
| 82 | +h3 { font-size: var(--fs-5); line-height: var(--lh-snug); } | |
| 83 | +h4 { font-size: var(--fs-3); } | |
| 43 | 84 | p { margin: 0 0 1em; } |
| 44 | 85 | a { color: var(--accent-2); text-decoration: none; } |
| 45 | 86 | a:hover { text-decoration: underline; text-underline-offset: 3px; } |
| 46 | −a:focus-visible, button:focus-visible, [tabindex]:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } | |
| 87 | +:focus { outline: none; } | |
| 88 | +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } | |
| 89 | +input:focus-visible, select:focus-visible, textarea:focus-visible { outline-offset: 0; border-color: var(--accent); border-radius: var(--r-sm); } | |
| 47 | 90 | code, pre, kbd, .mono { font-family: var(--mono); font-size: 0.9em; } |
| 48 | −code { background: var(--bg-2); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; } | |
| 49 | −pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; overflow: auto; line-height: 1.5; } | |
| 50 | −pre code { background: none; border: 0; padding: 0; font-size: 13px; } | |
| 51 | −kbd { background: var(--bg-2); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 5px; padding: 1px 6px; font-size: 12px; color: var(--fg-1); } | |
| 91 | +code { background: var(--bg-2); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; overflow-wrap: anywhere; } | |
| 92 | +pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--r-md); padding: 12px 14px; overflow: auto; line-height: 1.55; } | |
| 93 | +pre code { background: none; border: 0; padding: 0; font-size: var(--fs-1); } | |
| 94 | +kbd { background: var(--bg-2); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 5px; padding: 1px 6px; font-size: var(--fs-0); color: var(--fg-1); } | |
| 52 | 95 | hr { border: 0; border-top: 1px solid var(--line); margin: 28px 0; } |
| 53 | 96 | img, svg { max-width: 100%; } |
| 54 | −button { font: inherit; } | |
| 97 | +button { font: inherit; color: inherit; } | |
| 98 | +::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); } | |
| 99 | +@media (prefers-reduced-motion: reduce) { | |
| 100 | + *, *::before, *::after { animation-duration: 0.01ms; animation-iteration-count: 1; transition-duration: 0.01ms; scroll-behavior: auto; } | |
| 101 | +} | |
| 102 | + | |
| 103 | +/* ---- text utilities ------------------------------------------------------------------------ */ | |
| 55 | 104 | .muted { color: var(--fg-2); } |
| 56 | −.small { font-size: 13px; } | |
| 105 | +.small { font-size: var(--fs-1); } | |
| 57 | 106 | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } |
| 58 | −.skip-link { position: absolute; left: 12px; top: -48px; z-index: 100; background: var(--accent); color: var(--accent-ink); padding: 8px 12px; border-radius: 6px; font-weight: 600; } | |
| 107 | +.eyebrow { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-0); font-weight: 650; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); margin: 0 0 10px; } | |
| 108 | +.eyebrow svg { width: 14px; height: 14px; } | |
| 109 | +.lead { font-size: var(--fs-4); color: var(--fg-1); max-width: 62ch; line-height: var(--lh-body); } | |
| 110 | +.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono); font-size: var(--fs-1); } | |
| 111 | +.up { color: var(--up); } .down { color: var(--down); } .neutral { color: var(--neutral); } | |
| 112 | +.skip-link { position: absolute; left: 12px; top: -60px; z-index: 100; background: var(--accent); color: var(--accent-ink); padding: 10px 14px; border-radius: var(--r-sm); font-weight: 600; } | |
| 59 | 113 | .skip-link:focus { top: 12px; text-decoration: none; } |
| 60 | 114 | |
| 61 | −/* ---- shell ---- */ | |
| 115 | +/* ---- shell --------------------------------------------------------------------------------- */ | |
| 62 | 116 | .shell { min-height: 100dvh; display: flex; flex-direction: column; } |
| 63 | −.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 20px; padding: 0 20px; height: var(--topbar-h); background: var(--bg); background: color-mix(in srgb, var(--bg) 86%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); } | |
| 64 | −.brand { display: inline-flex; align-items: center; gap: 9px; color: var(--fg); font-weight: 650; letter-spacing: -0.01em; white-space: nowrap; } | |
| 117 | +.topbar { position: sticky; top: 0; z-index: var(--z-sticky); display: flex; align-items: center; gap: 16px; padding: 0 20px; height: var(--topbar-h); background: color-mix(in srgb, var(--bg) 86%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); } | |
| 118 | +.brand { display: inline-flex; align-items: center; gap: 9px; min-height: var(--tap); color: var(--fg); font-weight: 650; letter-spacing: -0.01em; white-space: nowrap; } | |
| 65 | 119 | .brand:hover { text-decoration: none; } |
| 66 | −.brand-mark { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 7px; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, var(--accent-2))); color: var(--accent-ink); font-family: var(--mono); font-size: 12px; font-weight: 700; } | |
| 120 | +.brand-mark { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 7px; background: var(--accent); color: var(--accent-ink); font-family: var(--mono); font-size: var(--fs-0); font-weight: 700; } | |
| 67 | 121 | .topnav { display: flex; gap: 2px; flex: 1; } |
| 68 | −.topnav a { color: var(--fg-1); padding: 6px 10px; border-radius: 6px; font-weight: 500; font-size: 14px; } | |
| 122 | +.topnav a { display: inline-flex; align-items: center; min-height: var(--tap); color: var(--fg-1); padding: 0 12px; border-radius: var(--r-sm); font-weight: 500; font-size: var(--fs-2); } | |
| 69 | 123 | .topnav a.active, .topnav a:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; } |
| 70 | −.topbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; } | |
| 71 | −.search-trigger { display: inline-flex; align-items: center; gap: 10px; height: 34px; padding: 0 10px 0 12px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); cursor: pointer; font-size: 13px; min-width: 190px; } | |
| 124 | +.topbar-right { display: flex; align-items: center; gap: 6px; margin-left: auto; } | |
| 125 | +.search-trigger { display: inline-flex; align-items: center; gap: 10px; height: var(--tap); padding: 0 10px 0 12px; border-radius: var(--r-md); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); cursor: pointer; font-size: var(--fs-1); min-width: 190px; } | |
| 72 | 126 | .search-trigger:hover { border-color: var(--line-3); color: var(--fg-1); } |
| 73 | 127 | .search-trigger kbd { margin-left: auto; } |
| 74 | −.icon-btn { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--fg-1); cursor: pointer; } | |
| 128 | +.icon-btn { display: inline-grid; place-items: center; width: var(--tap); height: var(--tap); border-radius: var(--r-md); border: 1px solid transparent; background: transparent; color: var(--fg-1); cursor: pointer; } | |
| 75 | 129 | .icon-btn:hover { background: var(--bg-2); color: var(--fg); } |
| 76 | 130 | .icon-btn svg { width: 18px; height: 18px; } |
| 131 | +.icon-btn[disabled] { opacity: .5; cursor: not-allowed; } | |
| 77 | 132 | .menu-btn { display: none; } |
| 78 | 133 | |
| 79 | −.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 8px 14px; border-radius: var(--radius); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg); cursor: pointer; font: inherit; font-weight: 500; font-size: 14px; line-height: 1.2; white-space: nowrap; transition: border-color .12s, background .12s, transform .06s; } | |
| 134 | +/* ---- buttons ------------------------------------------------------------------------------- */ | |
| 135 | +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: var(--tap); padding: 0 14px; border-radius: var(--r-md); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg); cursor: pointer; font: inherit; font-weight: 500; font-size: var(--fs-2); line-height: 1.2; white-space: nowrap; text-decoration: none; transition: border-color .12s, background .12s, transform .06s; } | |
| 80 | 136 | .btn:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-2); } |
| 81 | 137 | .btn:active { transform: translateY(1px); } |
| 138 | +.btn svg { width: 16px; height: 16px; flex: none; } | |
| 82 | 139 | .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 650; } |
| 83 | −.btn-primary:hover { background: var(--accent); background: color-mix(in srgb, var(--accent) 88%, white); border-color: color-mix(in srgb, var(--accent) 88%, white); } | |
| 140 | +.btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); color: var(--accent-ink); } | |
| 141 | +.btn-secondary { background: var(--bg-1); } | |
| 84 | 142 | .btn-ghost { background: transparent; border-color: transparent; color: var(--fg-1); } |
| 85 | 143 | .btn-ghost:hover { background: var(--bg-2); color: var(--fg); border-color: transparent; } |
| 86 | −.btn-lg { padding: 12px 20px; font-size: 15px; border-radius: 10px; } | |
| 87 | −.btn-sm { padding: 5px 10px; font-size: 13px; border-radius: 6px; } | |
| 88 | −.btn[disabled] { opacity: .55; cursor: not-allowed; } | |
| 144 | +.btn-danger { background: var(--danger-soft); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); } | |
| 145 | +.btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); } | |
| 146 | +.btn-lg { min-height: 44px; padding: 0 20px; font-size: var(--fs-3); border-radius: var(--r-md); } | |
| 147 | +.btn-sm { min-height: var(--tap); padding: 0 10px; font-size: var(--fs-1); border-radius: var(--r-sm); } | |
| 148 | +.btn[disabled], .btn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; } | |
| 149 | +.btn-block { width: 100%; } | |
| 89 | 150 | |
| 90 | −.page { width: 100%; max-width: var(--max); margin: 0 auto; padding: 40px 20px 80px; flex: 1; } | |
| 151 | +/* ---- page + footer ------------------------------------------------------------------------- */ | |
| 152 | +.page { width: 100%; max-width: var(--max); margin: 0 auto; padding: 40px 20px 80px; flex: 1; min-width: 0; } | |
| 91 | 153 | .page.narrow { max-width: 780px; } |
| 92 | 154 | .page-loading { flex: 1; min-height: 40vh; } |
| 93 | −.footer { border-top: 1px solid var(--line); padding: 28px 20px 36px; color: var(--fg-1); font-size: 13px; } | |
| 94 | −.footer-inner { max-width: var(--max); margin: 0 auto; display: grid; grid-template-columns: 1.4fr repeat(3, 1fr); gap: 24px; } | |
| 95 | −.footer h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-2); margin-bottom: 10px; } | |
| 96 | −.footer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } | |
| 97 | −.footer a { color: var(--fg-1); } | |
| 155 | +.footer { border-top: 1px solid var(--line); padding: 36px 20px 40px; color: var(--fg-1); font-size: var(--fs-1); } | |
| 156 | +.footer-inner { max-width: var(--max); margin: 0 auto; display: grid; grid-template-columns: 1.5fr repeat(4, 1fr); gap: 24px; } | |
| 157 | +.footer h4 { font-size: var(--fs-0); text-transform: uppercase; letter-spacing: .08em; color: var(--fg-2); margin: 0 0 10px; } | |
| 158 | +.footer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; } | |
| 159 | +.footer a { display: inline-flex; align-items: center; min-height: var(--tap); color: var(--fg-1); } | |
| 98 | 160 | .footer a:hover { color: var(--fg); } |
| 99 | −.footer-bottom { max-width: var(--max); margin: 24px auto 0; padding-top: 16px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; color: var(--fg-2); font-size: 12.5px; } | |
| 161 | +.footer-bottom { max-width: var(--max); margin: 24px auto 0; padding-top: 16px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; color: var(--fg-2); font-size: var(--fs-0); } | |
| 100 | 162 | |
| 101 | −.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 20px; } | |
| 163 | +/* ---- layout primitives --------------------------------------------------------------------- */ | |
| 164 | +.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-lg); padding: 20px; min-width: 0; } | |
| 165 | +.card-sm { padding: 14px 16px; border-radius: var(--r-md); } | |
| 102 | 166 | .grid { display: grid; gap: 16px; } |
| 103 | 167 | .grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } |
| 104 | 168 | .grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } |
| 105 | 169 | .grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } |
| 106 | −.eyebrow { font-size: 12px; font-weight: 650; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); margin-bottom: 10px; } | |
| 107 | −.lead { font-size: 17px; color: var(--fg-1); max-width: 62ch; } | |
| 170 | +.stack { display: flex; flex-direction: column; gap: var(--sp-3); } | |
| 171 | +.row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } | |
| 108 | 172 | |
| 109 | −table { border-collapse: collapse; width: 100%; font-size: 14px; } | |
| 173 | +/* ---- tables -------------------------------------------------------------------------------- */ | |
| 174 | +table { border-collapse: collapse; width: 100%; font-size: var(--fs-2); } | |
| 110 | 175 | th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; } |
| 111 | −th { color: var(--fg-1); font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: .04em; } | |
| 112 | −tbody tr:hover { background: var(--bg-2); background: color-mix(in srgb, var(--bg-2) 60%, transparent); } | |
| 113 | −.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); } | |
| 114 | −.table-wrap table { min-width: 520px; } | |
| 115 | −.table-wrap th { background: var(--bg-1); position: sticky; top: 0; white-space: nowrap; overflow-wrap: normal; } | |
| 176 | +th { color: var(--fg-1); font-weight: 600; font-size: var(--fs-0); text-transform: uppercase; letter-spacing: .04em; } | |
| 177 | +tbody tr:hover { background: color-mix(in srgb, var(--bg-2) 60%, transparent); } | |
| 178 | +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--r-md); max-width: 100%; -webkit-overflow-scrolling: touch; } | |
| 179 | +.table-wrap th { background: var(--bg-1); position: sticky; top: 0; white-space: nowrap; z-index: 1; } | |
| 116 | 180 | .table-wrap tr:last-child td { border-bottom: 0; } |
| 117 | −.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono); font-size: 13px; } | |
| 181 | +.table-wrap.dense th, .table-wrap.dense td { padding: 6px 10px; font-size: var(--fs-1); } | |
| 182 | +.table-wrap td.num, .table-wrap th.num { text-align: right; } | |
| 183 | +.table-wrap.sticky-first th:first-child, .table-wrap.sticky-first td:first-child { position: sticky; left: 0; background: var(--bg-1); z-index: 2; } | |
| 184 | +.table-wrap.sticky-first th:first-child { z-index: 3; } | |
| 185 | + | |
| 186 | +/* ---- forms --------------------------------------------------------------------------------- */ | |
| 187 | +input, select, textarea { font: inherit; color: var(--fg); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-sm); padding: 0 10px; min-height: var(--tap); max-width: 100%; } | |
| 188 | +textarea { padding: 8px 10px; line-height: 1.5; } | |
| 189 | +input::placeholder, textarea::placeholder { color: var(--fg-3); } | |
| 190 | +input[aria-invalid="true"], select[aria-invalid="true"], textarea[aria-invalid="true"], input.invalid, select.invalid { border-color: var(--danger); } | |
| 191 | +input[type="checkbox"], input[type="radio"] { min-height: 0; width: 18px; height: 18px; accent-color: var(--accent); } | |
| 118 | 192 | |
| 119 | −input, select, textarea { font: inherit; color: var(--fg); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: 6px; padding: 8px 10px; } | |
| 120 | −input:focus, select:focus, textarea:focus { outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); border-color: var(--accent); } | |
| 121 | −::selection { background: var(--accent); background: color-mix(in srgb, var(--accent) 35%, transparent); } | |
| 122 | −@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } | |
| 193 | +/* ---- dashboard-oriented utilities ---------------------------------------------------------- */ | |
| 194 | +.tabs-nav { display: flex; gap: 2px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; } | |
| 195 | +.tabs-nav::-webkit-scrollbar { display: none; } | |
| 196 | +.tabs-nav a, .tabs-nav button { position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 12px; border: 0; background: transparent; color: var(--fg-2); font-weight: 500; font-size: var(--fs-2); white-space: nowrap; cursor: pointer; border-radius: var(--r-sm) var(--r-sm) 0 0; } | |
| 197 | +.tabs-nav a:hover, .tabs-nav button:hover { color: var(--fg); text-decoration: none; } | |
| 198 | +.tabs-nav a.active, .tabs-nav button.active, .tabs-nav [aria-selected="true"], .tabs-nav [aria-current="page"] { color: var(--fg); } | |
| 199 | +.tabs-nav a.active::after, .tabs-nav button.active::after, .tabs-nav [aria-selected="true"]::after, .tabs-nav [aria-current="page"]::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--accent); border-radius: 2px; } | |
| 200 | +.stat { display: flex; flex-direction: column; gap: 4px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg-1); min-width: 0; } | |
| 201 | +.stat-label { color: var(--fg-2); text-transform: uppercase; letter-spacing: .06em; font-size: var(--fs-0); font-weight: 600; } | |
| 202 | +.stat-value { font-size: var(--fs-6); font-weight: 650; color: var(--fg); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; line-height: 1.1; overflow-wrap: anywhere; } | |
| 203 | +.stat-hint { color: var(--fg-2); font-size: var(--fs-0); } | |
| 204 | +.stat-delta { font-size: var(--fs-1); font-weight: 600; } | |
| 205 | +.skeleton { display: inline-block; width: 90px; height: 1em; border-radius: 6px; background: linear-gradient(90deg, var(--bg-2), var(--bg-3), var(--bg-2)); background-size: 200% 100%; animation: shimmer 1.2s infinite; vertical-align: middle; color: transparent; } | |
| 206 | +.skeleton-block { display: block; width: 100%; } | |
| 207 | +.skeleton-text { height: .9em; margin: .35em 0; } | |
| 208 | +@keyframes shimmer { to { background-position: -200% 0; } } | |
| 209 | +.empty-state, .error-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; text-align: center; padding: 32px 20px; border: 1px dashed var(--line-2); border-radius: var(--r-md); color: var(--fg-1); } | |
| 210 | +.empty-state svg, .error-state svg { width: 28px; height: 28px; color: var(--fg-3); } | |
| 211 | +.empty-state h3, .error-state h3 { margin: 4px 0 0; font-size: var(--fs-4); } | |
| 212 | +.empty-state p, .error-state p { margin: 0; max-width: 46ch; color: var(--fg-2); font-size: var(--fs-2); } | |
| 213 | +.empty-state .actions, .error-state .actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; margin-top: 6px; } | |
| 214 | +.error-state { border-style: solid; border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: color-mix(in srgb, var(--danger) 5%, var(--bg-1)); } | |
| 215 | +.error-state svg { color: var(--danger); } | |
| 216 | +.error-state.is-429 { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); background: color-mix(in srgb, var(--warn) 6%, var(--bg-1)); } | |
| 217 | +.error-state.is-429 svg { color: var(--warn); } | |
| 218 | +.error-state .countdown { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: var(--fs-6); font-weight: 600; color: var(--fg); } | |
| 123 | 219 | |
| 124 | −/* ---- mobile drawer ---- */ | |
| 125 | −.drawer-backdrop { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, .62); } | |
| 126 | −.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 61; width: min(320px, 86vw); background: var(--bg-1); background-color: var(--bg-1); isolation: isolate; overflow-y: auto; -webkit-overflow-scrolling: touch; border-left: 1px solid var(--line); padding: 16px; display: flex; flex-direction: column; gap: 6px; box-shadow: var(--shadow); } | |
| 127 | −.drawer-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } | |
| 128 | −.drawer a.navlink { display: block; padding: 10px 12px; border-radius: 8px; color: var(--fg-1); font-weight: 500; } | |
| 220 | +/* ---- mobile drawer ------------------------------------------------------------------------- */ | |
| 221 | +.drawer-backdrop { position: fixed; inset: 0; z-index: var(--z-drawer); background: rgba(0, 0, 0, .62); } | |
| 222 | +.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: calc(var(--z-drawer) + 1); width: min(320px, 86vw); background: var(--bg-1); isolation: isolate; overflow-y: auto; -webkit-overflow-scrolling: touch; border-left: 1px solid var(--line); padding: 12px 16px calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-2); } | |
| 223 | +.drawer-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; min-height: var(--tap-mobile); } | |
| 224 | +.drawer a.navlink { display: flex; align-items: center; min-height: var(--tap-mobile); padding: 0 12px; border-radius: var(--r-md); color: var(--fg-1); font-weight: 500; font-size: var(--fs-3); } | |
| 129 | 225 | .drawer a.navlink.active, .drawer a.navlink:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; } |
| 130 | −.drawer-foot { margin-top: auto; display: grid; gap: 8px; } | |
| 226 | +.drawer-foot { margin-top: auto; display: grid; gap: 8px; padding-top: 12px; } | |
| 227 | +.drawer-foot .btn { min-height: var(--tap-mobile); } | |
| 131 | 228 | |
| 132 | −@media (max-width: 900px) { | |
| 229 | +/* ---- responsive ---------------------------------------------------------------------------- */ | |
| 230 | +@media (max-width: 1200px) { | |
| 231 | + .footer-inner { grid-template-columns: 1.4fr repeat(2, 1fr); } | |
| 232 | +} | |
| 233 | +@media (max-width: 960px) { | |
| 234 | + :root { --tap: var(--tap-mobile); } | |
| 133 | 235 | .topnav { display: none; } |
| 134 | 236 | .menu-btn { display: inline-grid; } |
| 135 | − .search-trigger { min-width: 0; padding: 0 9px; } | |
| 237 | + .search-trigger { min-width: 0; width: var(--tap-mobile); padding: 0; justify-content: center; } | |
| 136 | 238 | .search-trigger span, .search-trigger kbd { display: none; } |
| 137 | − .topbar { gap: 12px; padding: 0 14px; } | |
| 239 | + .topbar { gap: 8px; padding: 0 12px; } | |
| 240 | + .topbar-right { gap: 2px; } | |
| 138 | 241 | .footer-inner { grid-template-columns: 1fr 1fr; } |
| 139 | 242 | .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; } |
| 140 | 243 | .page { padding: 28px 16px 64px; } |
| 244 | + .btn-sm { min-height: var(--tap-mobile); } | |
| 141 | 245 | } |
| 142 | −@media (max-width: 600px) { | |
| 246 | +@media (max-width: 640px) { | |
| 143 | 247 | .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } |
| 144 | − .footer-inner { grid-template-columns: 1fr; } | |
| 248 | + .footer-inner { grid-template-columns: 1fr; gap: 18px; } | |
| 145 | 249 | .topbar-right .btn-signin { display: none; } |
| 250 | + h1 { font-size: var(--fs-7); } | |
| 251 | + h2 { font-size: var(--fs-6); } | |
| 146 | 252 | } |
modified
hfmarketdata/web/src/components/Badge.jsx
+10 −4
@@ -1,8 +1,8 @@ | ||
| 1 | −// <Badge> generic pill · <MethodBadge method="GET"> coloured HTTP verb · <StatusBadge code={404}>. | |
| 1 | +// <Badge tone> generic pill · <MethodBadge method="GET"> HTTP verb · <StatusBadge code={404}> · <TierBadge tier="free">. | |
| 2 | 2 | import React from 'react' |
| 3 | 3 | |
| 4 | −export default function Badge({ tone = 'neutral', children, className = '', title }) { | |
| 5 | − return <span className={`badge badge-${tone} ${className}`} title={title}>{children}</span> | |
| 4 | +export default function Badge({ tone = 'neutral', children, className = '', title, dot = false }) { | |
| 5 | + return <span className={`badge badge-${tone} ${className}`} title={title}>{dot && <span className="badge-dot" aria-hidden="true" />}{children}</span> | |
| 6 | 6 | } |
| 7 | 7 | |
| 8 | 8 | export function MethodBadge({ method = 'GET', className = '' }) { |
@@ -12,6 +12,12 @@ export function MethodBadge({ method = 'GET', className = '' }) { | ||
| 12 | 12 | |
| 13 | 13 | export function StatusBadge({ code }) { |
| 14 | 14 | const n = Number(code) |
| 15 | − const tone = n < 300 ? 'success' : n < 400 ? 'info' : n < 500 ? 'warn' : 'danger' | |
| 15 | + const tone = n < 300 ? 'success' : n < 400 ? 'info' : n === 429 ? 'warn' : n < 500 ? 'warn' : 'danger' | |
| 16 | 16 | return <Badge tone={tone} className="mono">{code}</Badge> |
| 17 | 17 | } |
| 18 | + | |
| 19 | +const TIERS = { keyless: ['Keyless', 'neutral'], free: ['Free', 'success'], high_usage: ['High usage', 'info'], unlimited: ['Unlimited', 'info'], admin: ['Admin', 'warn'] } | |
| 20 | +export function TierBadge({ tier = 'keyless' }) { | |
| 21 | + const [label, tone] = TIERS[tier] || [tier, 'neutral'] | |
| 22 | + return <Badge tone={tone} className="badge-tier">{label}</Badge> | |
| 23 | +} | |
added
hfmarketdata/web/src/components/BrandLogos.jsx
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +// Inline brand marks used by the homepage and the Integrations pages — one source, 40×40 viewBox, no requests. | |
| 2 | +import React from 'react' | |
| 3 | + | |
| 4 | +const wrap = (children, bg, stroke) => ( | |
| 5 | + <svg viewBox="0 0 40 40" aria-hidden="true" className="brand-logo"> | |
| 6 | + <rect width="40" height="40" rx="10" fill={bg} stroke={stroke} /> | |
| 7 | + {children} | |
| 8 | + </svg> | |
| 9 | +) | |
| 10 | + | |
| 11 | +export const LOGOS = { | |
| 12 | + claude: () => wrap(<path d="M12 28 20 12l8 16h-4l-4-8-4 8z" fill="#fff" />, '#d97757'), | |
| 13 | + cursor: () => wrap(<><path d="M20 8l11 6.5v11L20 32 9 25.5v-11z" fill="none" stroke="#fff" strokeWidth="2" /><path d="M20 8v24M9 14.5l11 6.5 11-6.5" stroke="#fff" strokeWidth="1.5" fill="none" /></>, '#111'), | |
| 14 | + codex: () => wrap(<><circle cx="20" cy="20" r="9" fill="none" stroke="#fff" strokeWidth="2.2" /><circle cx="20" cy="20" r="3" fill="#fff" /></>, '#0a0c10', '#313847'), | |
| 15 | + mcp: () => wrap(<path d="M12 26l8-14 8 14M15 22h10" fill="none" stroke="var(--accent)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />, 'var(--bg-2)', 'var(--line-2)'), | |
| 16 | + skills: () => wrap(<><path d="M13 12h14v16H13z" fill="none" stroke="var(--accent-2)" strokeWidth="2" strokeLinejoin="round" /><path d="M16 17h8M16 21h8M16 25h5" stroke="var(--accent-2)" strokeWidth="2" strokeLinecap="round" /></>, 'var(--bg-2)', 'var(--line-2)'), | |
| 17 | + python: () => wrap(<><path d="M19.5 9c-4 0-5.5 1.6-5.5 4v3h6v1h-8.5C9 17 8 18.6 8 21s1 4 3.5 4H14v-3c0-2.4 1.6-4 4-4h5c2 0 3.5-1.5 3.5-3.5V13c0-2.4-1.6-4-4-4h-3z" fill="#3b82f6" /><path d="M20.5 31c4 0 5.5-1.6 5.5-4v-3h-6v-1h8.5c2.5 0 3.5-1.6 3.5-4s-1-4-3.5-4H26v3c0 2.4-1.6 4-4 4h-5c-2 0-3.5 1.5-3.5 3.5V27c0 2.4 1.6 4 4 4h3z" fill="#fbbf24" /></>, 'var(--bg-2)', 'var(--line-2)'), | |
| 18 | + r: () => wrap(<><ellipse cx="20" cy="18" rx="12" ry="8" fill="none" stroke="#9ca3af" strokeWidth="2.5" /><path d="M15 30V16h6a4 4 0 0 1 0 8h-4m4 0 4 6" fill="none" stroke="#2563eb" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" /></>, 'var(--bg-2)', 'var(--line-2)'), | |
| 19 | + javascript: () => wrap(<><path d="M22 23.5v-8h2.6v7.9c0 2.6-1.4 3.9-3.8 3.9-1.8 0-3-.8-3.6-2l2.1-1.2c.3.6.7.9 1.4.9.8 0 1.3-.4 1.3-1.5zm-9.6 2.7-2.1 1.2c.6 1.5 2 2.7 4.3 2.7 2.4 0 4-1.3 4-3.6 0-2.1-1.2-3-3.4-3.9l-.6-.3c-1.1-.5-1.6-.8-1.6-1.5s.5-1.1 1.2-1.1c.7 0 1.2.3 1.6 1.1l2-1.3c-.8-1.5-2-2-3.6-2-2.3 0-3.7 1.4-3.7 3.3 0 2 1.2 3 3 3.8l.6.3c1.2.5 1.9.8 1.9 1.7 0 .7-.7 1.2-1.7 1.2-1.2 0-1.9-.6-2.4-1.5z" fill="#111" /></>, '#f7df1e'), | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** <BrandLogo name="claude|cursor|codex|mcp|skills|python|r|javascript" size={40} /> */ | |
| 23 | +export default function BrandLogo({ name, size = 40, className = '' }) { | |
| 24 | + const L = LOGOS[name] | |
| 25 | + if (!L) return null | |
| 26 | + return <span className={`brand-logo-wrap ${className}`} style={{ width: size, height: size }}>{L()}</span> | |
| 27 | +} | |
added
hfmarketdata/web/src/components/Button.jsx
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +// <Button> / <IconButton> — render a <button>, or a router <Link> / <a> when `to` / `href` is given. | |
| 2 | +import React from 'react' | |
| 3 | +import { Link } from 'react-router-dom' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * @param {'primary'|'secondary'|'ghost'|'danger'} [variant='secondary'] | |
| 7 | + * @param {'sm'|'md'|'lg'} [size='md'] | |
| 8 | + * @param {string} [to] router link | |
| 9 | + * @param {string} [href] external / mailto link | |
| 10 | + * @param {boolean} [block] full width | |
| 11 | + * @param {boolean} [loading] disables and shows a spinner | |
| 12 | + */ | |
| 13 | +export default function Button({ variant = 'secondary', size = 'md', to, href, block = false, loading = false, disabled, className = '', children, type = 'button', ...rest }) { | |
| 14 | + const cls = `btn ${variant !== 'secondary' ? `btn-${variant}` : ''} ${size !== 'md' ? `btn-${size}` : ''} ${block ? 'btn-block' : ''} ${loading ? 'is-loading' : ''} ${className}`.replace(/\s+/g, ' ').trim() | |
| 15 | + const inner = <>{loading && <span className="spinner" aria-hidden="true" />}{children}</> | |
| 16 | + if (to && !disabled) return <Link to={to} className={cls} {...rest}>{inner}</Link> | |
| 17 | + if (href && !disabled) return <a href={href} className={cls} {...rest}>{inner}</a> | |
| 18 | + return <button type={type} className={cls} disabled={disabled || loading} aria-busy={loading || undefined} {...rest}>{inner}</button> | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** Square icon-only button (36 px desktop, 44 px mobile). `label` is required for accessibility. */ | |
| 22 | +export function IconButton({ label, to, href, className = '', children, type = 'button', ...rest }) { | |
| 23 | + const cls = `icon-btn ${className}`.trim() | |
| 24 | + if (to) return <Link to={to} className={cls} aria-label={label} title={label} {...rest}>{children}</Link> | |
| 25 | + if (href) return <a href={href} className={cls} aria-label={label} title={label} {...rest}>{children}</a> | |
| 26 | + return <button type={type} className={cls} aria-label={label} title={label} {...rest}>{children}</button> | |
| 27 | +} | |
modified
hfmarketdata/web/src/components/Callout.jsx
+10 −6
@@ -1,18 +1,22 @@ | ||
| 1 | −// <Callout type="info|tip|warning|danger" title?> — highlighted aside. | |
| 1 | +// <Callout type="info|tip|success|warning|danger" title? action?> — highlighted aside. | |
| 2 | +// `kind` is accepted as an alias of `type` (info | warn | danger | success) for the former PgCallout API. | |
| 2 | 3 | import React from 'react' |
| 3 | −import { BoltIcon, InfoIcon, WarnIcon } from './Icons.jsx' | |
| 4 | +import { BoltIcon, CheckCircleIcon, InfoIcon, WarnIcon } from './Icons.jsx' | |
| 4 | 5 | |
| 5 | −const ICONS = { info: InfoIcon, tip: BoltIcon, warning: WarnIcon, danger: WarnIcon } | |
| 6 | +const ICONS = { info: InfoIcon, tip: BoltIcon, success: CheckCircleIcon, warning: WarnIcon, danger: WarnIcon } | |
| 7 | +const KIND = { warn: 'warning', info: 'info', danger: 'danger', success: 'success', tip: 'tip', warning: 'warning' } | |
| 6 | 8 | |
| 7 | −export default function Callout({ type = 'info', title, children, className = '' }) { | |
| 8 | − const Icon = ICONS[type] || InfoIcon | |
| 9 | +export default function Callout({ type, kind, title, children, action = null, className = '', compact = false, ...rest }) { | |
| 10 | + const t = KIND[type || kind] || 'info' | |
| 11 | + const Icon = ICONS[t] | |
| 9 | 12 | return ( |
| 10 | − <aside className={`callout callout-${type} ${className}`} role={type === 'danger' || type === 'warning' ? 'alert' : 'note'}> | |
| 13 | + <aside className={`callout callout-${t} ${compact ? 'callout-compact' : ''} ${className}`} role={t === 'danger' || t === 'warning' ? 'alert' : 'status'} {...rest}> | |
| 11 | 14 | <span className="callout-icon"><Icon /></span> |
| 12 | 15 | <div className="callout-body"> |
| 13 | 16 | {title && <div className="callout-title">{title}</div>} |
| 14 | 17 | <div>{children}</div> |
| 15 | 18 | </div> |
| 19 | + {action && <div className="callout-action">{action}</div>} | |
| 16 | 20 | </aside> |
| 17 | 21 | ) |
| 18 | 22 | } |
added
hfmarketdata/web/src/components/Card.jsx
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +// <Card> — surface with border; `as` for semantic element, `to` renders a router Link card. | |
| 2 | +import React from 'react' | |
| 3 | +import { Link } from 'react-router-dom' | |
| 4 | + | |
| 5 | +export default function Card({ as: Tag = 'div', to, size = 'md', className = '', children, ...rest }) { | |
| 6 | + const cls = `card ${size === 'sm' ? 'card-sm' : ''} ${to ? 'card-link' : ''} ${className}`.replace(/\s+/g, ' ').trim() | |
| 7 | + if (to) return <Link to={to} className={cls} {...rest}>{children}</Link> | |
| 8 | + return <Tag className={cls} {...rest}>{children}</Tag> | |
| 9 | +} | |
deleted
hfmarketdata/web/src/components/Code.jsx
+0 −110
@@ -1,110 +0,0 @@ | ||
| 1 | −// <Code> — syntax-highlighted block (prism-react-renderer, bundled grammars + tiny bash/R grammars) with a copy button. | |
| 2 | −// Colors come from CSS variables so it follows the dark/light theme without a second stylesheet. | |
| 3 | −import React, { useCallback, useEffect, useState } from 'react' | |
| 4 | −import { Highlight, Prism } from 'prism-react-renderer' | |
| 5 | −import { CheckIcon, CopyIcon } from './Icons.jsx' | |
| 6 | − | |
| 7 | −// prism-react-renderer ships no bash/R grammar — minimal ones, enough for API snippets. | |
| 8 | −if (!Prism.languages.bash) { | |
| 9 | − Prism.languages.bash = { | |
| 10 | − comment: /#.*/, | |
| 11 | − string: [{ pattern: /"(?:\\[\s\S]|\$\([^)]*\)|[^"\\])*"/, greedy: true, inside: { variable: /\$\{?[\w.]+\}?/ } }, { pattern: /'[^']*'/, greedy: true }], | |
| 12 | − variable: /\$\{?[\w.]+\}?/, | |
| 13 | − flag: { pattern: /(^|\s)--?[\w-]+/, lookbehind: true, alias: 'attr-name' }, | |
| 14 | − function: /\b(?:curl|jq|python3?|pip|npm|npx|node|export|echo|cat|head|wget|Rscript)\b/, | |
| 15 | − operator: /\\$|\||&&|>/m, | |
| 16 | − punctuation: /[{}()]/, | |
| 17 | − } | |
| 18 | − Prism.languages.sh = Prism.languages.shell = Prism.languages.bash | |
| 19 | −} | |
| 20 | −if (!Prism.languages.r) { | |
| 21 | − Prism.languages.r = { | |
| 22 | − comment: /#.*/, | |
| 23 | − string: { pattern: /(["'])(?:\\.|(?!\1)[^\\\r\n])*\1/, greedy: true }, | |
| 24 | − 'percent-operator': { pattern: /%[^%\s]*%/, alias: 'operator' }, | |
| 25 | − boolean: /\b(?:TRUE|FALSE|T|F)\b/, | |
| 26 | − keyword: /\b(?:NA|NULL|Inf|NaN|if|else|for|while|repeat|function|return|in|next|break|library|require)\b/, | |
| 27 | − number: /\b(?:0x[\dA-Fa-f]+|\d*\.?\d+(?:e[+-]?\d+)?)[iL]?\b/i, | |
| 28 | − function: /\b[a-zA-Z_.][\w.]*(?=\s*\()/, | |
| 29 | − operator: /->?>?|<(?:=|<?-)?|[>=!]=?|::?|&&?|\|\|?|[+*\/^$@~]/, | |
| 30 | − punctuation: /[(){}\[\],;]/, | |
| 31 | − } | |
| 32 | −} | |
| 33 | − | |
| 34 | −const THEME = { | |
| 35 | − plain: { color: 'var(--code-fg)', backgroundColor: 'transparent' }, | |
| 36 | − styles: [ | |
| 37 | − { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--code-comment)', fontStyle: 'italic' } }, | |
| 38 | − { types: ['punctuation', 'operator'], style: { color: 'var(--code-punct)' } }, | |
| 39 | − { types: ['property', 'attr-name', 'variable', 'flag'], style: { color: 'var(--code-key)' } }, | |
| 40 | − { types: ['string', 'char', 'url', 'inserted'], style: { color: 'var(--code-string)' } }, | |
| 41 | − { types: ['number', 'boolean', 'constant', 'symbol'], style: { color: 'var(--code-number)' } }, | |
| 42 | − { types: ['keyword', 'builtin', 'important', 'atrule', 'tag'], style: { color: 'var(--code-keyword)' } }, | |
| 43 | − { types: ['function', 'class-name', 'percent-operator'], style: { color: 'var(--code-func)' } }, | |
| 44 | − { types: ['deleted'], style: { color: 'var(--danger)' } }, | |
| 45 | − ], | |
| 46 | −} | |
| 47 | − | |
| 48 | −export function useCopy(timeout = 1600) { | |
| 49 | − const [copied, setCopied] = useState(false) | |
| 50 | − const copy = useCallback(async text => { | |
| 51 | − try { | |
| 52 | − await navigator.clipboard.writeText(text) | |
| 53 | − } catch { | |
| 54 | − const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', '') | |
| 55 | − ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select() | |
| 56 | − try { document.execCommand('copy') } finally { document.body.removeChild(ta) } | |
| 57 | − } | |
| 58 | − setCopied(true) | |
| 59 | − }, []) | |
| 60 | − useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), timeout); return () => clearTimeout(t) }, [copied, timeout]) | |
| 61 | − return { copied, copy } | |
| 62 | −} | |
| 63 | − | |
| 64 | −export function CopyButton({ text, label = 'Copy to clipboard', className = '' }) { | |
| 65 | − const { copied, copy } = useCopy() | |
| 66 | − return ( | |
| 67 | − <button type="button" className={`copy-btn ${copied ? 'copied' : ''} ${className}`} onClick={() => copy(text)} aria-label={copied ? 'Copied' : label} title={label}> | |
| 68 | − {copied ? <CheckIcon /> : <CopyIcon />}<span>{copied ? 'Copied' : 'Copy'}</span> | |
| 69 | − </button> | |
| 70 | − ) | |
| 71 | −} | |
| 72 | − | |
| 73 | −/** | |
| 74 | − * Code block. | |
| 75 | − * @param {string} code source text | |
| 76 | − * @param {string} language bash | python | javascript | r | json | http | text … | |
| 77 | − * @param {string} [title] optional header label (e.g. file name / "Response") | |
| 78 | − * @param {boolean} [copy=true] | |
| 79 | − * @param {number} [maxHeight] px — scrolls inside when longer | |
| 80 | − * @param {boolean} [lineNumbers=false] | |
| 81 | − */ | |
| 82 | −export default function Code({ code = '', language = 'text', title, copy = true, maxHeight, lineNumbers = false, className = '', children }) { | |
| 83 | − const src = (code || children || '').toString().replace(/\n$/, '') | |
| 84 | − const lang = Prism.languages[language] ? language : 'text' | |
| 85 | − return ( | |
| 86 | − <div className={`code ${className}`} data-language={language}> | |
| 87 | − {(title || copy) && ( | |
| 88 | − <div className="code-head"> | |
| 89 | − <span className="code-title">{title || language}</span> | |
| 90 | − {copy && <CopyButton text={src} />} | |
| 91 | − </div> | |
| 92 | − )} | |
| 93 | − <Highlight code={src} language={lang} theme={THEME}> | |
| 94 | − {({ className: cls, tokens, getLineProps, getTokenProps }) => ( | |
| 95 | − <pre className={`${cls} code-pre`} style={maxHeight ? { maxHeight } : undefined} tabIndex={0}> | |
| 96 | − <code> | |
| 97 | − {tokens.map((line, i) => ( | |
| 98 | − <span key={i} {...getLineProps({ line })} className="code-line"> | |
| 99 | − {lineNumbers && <span className="code-ln" aria-hidden="true">{i + 1}</span>} | |
| 100 | − {line.map((token, k) => <span key={k} {...getTokenProps({ token })} />)} | |
| 101 | − {'\n'} | |
| 102 | − </span> | |
| 103 | − ))} | |
| 104 | − </code> | |
| 105 | − </pre> | |
| 106 | − )} | |
| 107 | − </Highlight> | |
| 108 | − </div> | |
| 109 | − ) | |
| 110 | −} | |
added
hfmarketdata/web/src/components/CodeBlock.jsx
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +// <CodeBlock> — the one code block of the site: title, copy, wrap toggle, optional line numbers, theme-driven | |
| 2 | +// colours. Prism is a lazy chunk: the block renders as a plain <pre> immediately and colours in when it loads. | |
| 3 | +import React, { Suspense, lazy, useState } from 'react' | |
| 4 | +import CopyButton from './CopyButton.jsx' | |
| 5 | +import { WrapIcon } from './Icons.jsx' | |
| 6 | + | |
| 7 | +const Highlighter = lazy(() => import('./CodeHighlight.jsx')) | |
| 8 | +const WRAP_KEY = 'hfmd.codewrap' | |
| 9 | +const readWrap = () => { try { return localStorage.getItem(WRAP_KEY) === '1' } catch { return false } } | |
| 10 | + | |
| 11 | +function Plain({ code, lineNumbers }) { | |
| 12 | + return ( | |
| 13 | + <code> | |
| 14 | + {code.split('\n').map((line, i) => ( | |
| 15 | + <span key={i} className="code-line">{lineNumbers && <span className="code-ln" aria-hidden="true">{i + 1}</span>}{line}{'\n'}</span> | |
| 16 | + ))} | |
| 17 | + </code> | |
| 18 | + ) | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * @param {string} code source text (or children) | |
| 23 | + * @param {string} [language='text'] bash | python | javascript | r | json | http | toml | text … | |
| 24 | + * @param {string} [title] header label (file name, "Response · 200"…); header hidden when no title/copy/wrap | |
| 25 | + * @param {boolean} [copy=true] | |
| 26 | + * @param {boolean} [wrapToggle=true] show the wrap-lines toggle (persisted in localStorage `hfmd.codewrap`) | |
| 27 | + * @param {boolean} [wrap] force initial wrap state | |
| 28 | + * @param {number} [maxHeight] px — scrolls inside when longer | |
| 29 | + * @param {boolean} [lineNumbers=false] | |
| 30 | + * @param {ReactNode} [actions] extra header actions | |
| 31 | + * @param {ReactNode} [footer] caption under the code (source note…) | |
| 32 | + */ | |
| 33 | +export default function CodeBlock({ code = '', language = 'text', title, copy = true, wrapToggle = true, wrap, maxHeight, lineNumbers = false, actions, footer, className = '', children, ...rest }) { | |
| 34 | + const src = (code || children || '').toString().replace(/\n$/, '') | |
| 35 | + const [wrapped, setWrapped] = useState(() => (wrap !== undefined ? wrap : readWrap())) | |
| 36 | + const toggleWrap = () => setWrapped(w => { try { localStorage.setItem(WRAP_KEY, w ? '0' : '1') } catch { /* private mode */ } return !w }) | |
| 37 | + const head = title || copy || wrapToggle || actions | |
| 38 | + return ( | |
| 39 | + <div className={`code ${wrapped ? 'code-wrap' : ''} ${className}`} data-language={language} {...rest}> | |
| 40 | + {head && ( | |
| 41 | + <div className="code-head"> | |
| 42 | + <span className="code-title">{title || language}</span> | |
| 43 | + <span className="code-actions"> | |
| 44 | + {actions} | |
| 45 | + {wrapToggle && <button type="button" className={`copy-btn copy-sm copy-ghost copy-icon-only ${wrapped ? 'copied' : ''}`} onClick={toggleWrap} aria-pressed={wrapped} aria-label={wrapped ? 'Disable line wrapping' : 'Wrap long lines'} title="Wrap lines"><WrapIcon /></button>} | |
| 46 | + {copy && <CopyButton text={src} />} | |
| 47 | + </span> | |
| 48 | + </div> | |
| 49 | + )} | |
| 50 | + <pre className="code-pre" style={maxHeight ? { maxHeight } : undefined} tabIndex={0}> | |
| 51 | + <Suspense fallback={<Plain code={src} lineNumbers={lineNumbers} />}> | |
| 52 | + <Highlighter code={src} language={language} lineNumbers={lineNumbers} /> | |
| 53 | + </Suspense> | |
| 54 | + </pre> | |
| 55 | + {footer && <div className="code-foot">{footer}</div>} | |
| 56 | + </div> | |
| 57 | + ) | |
| 58 | +} | |
added
hfmarketdata/web/src/components/CodeHighlight.jsx
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +// Prism highlighter (lazy chunk) — colours are CSS variables (--code-*) so every theme works with one stylesheet. | |
| 2 | +// prism-react-renderer ships no bash/R grammar; minimal ones cover API snippets. | |
| 3 | +import React from 'react' | |
| 4 | +import { Highlight, Prism } from 'prism-react-renderer' | |
| 5 | + | |
| 6 | +if (!Prism.languages.bash) { | |
| 7 | + Prism.languages.bash = { | |
| 8 | + comment: /#.*/, | |
| 9 | + string: [{ pattern: /"(?:\\[\s\S]|\$\([^)]*\)|[^"\\])*"/, greedy: true, inside: { variable: /\$\{?[\w.]+\}?/ } }, { pattern: /'[^']*'/, greedy: true }], | |
| 10 | + variable: /\$\{?[\w.]+\}?/, | |
| 11 | + flag: { pattern: /(^|\s)--?[\w-]+/, lookbehind: true, alias: 'attr-name' }, | |
| 12 | + function: /\b(?:curl|jq|python3?|pip|npm|npx|node|export|echo|cat|head|wget|Rscript|unzip|claude|codex|websocat)\b/, | |
| 13 | + operator: /\\$|\||&&|>/m, | |
| 14 | + punctuation: /[{}()]/, | |
| 15 | + } | |
| 16 | + Prism.languages.sh = Prism.languages.shell = Prism.languages.bash | |
| 17 | +} | |
| 18 | +if (!Prism.languages.r) { | |
| 19 | + Prism.languages.r = { | |
| 20 | + comment: /#.*/, | |
| 21 | + string: { pattern: /(["'])(?:\\.|(?!\1)[^\\\r\n])*\1/, greedy: true }, | |
| 22 | + 'percent-operator': { pattern: /%[^%\s]*%/, alias: 'operator' }, | |
| 23 | + boolean: /\b(?:TRUE|FALSE|T|F)\b/, | |
| 24 | + keyword: /\b(?:NA|NULL|Inf|NaN|if|else|for|while|repeat|function|return|in|next|break|library|require)\b/, | |
| 25 | + number: /\b(?:0x[\dA-Fa-f]+|\d*\.?\d+(?:e[+-]?\d+)?)[iL]?\b/i, | |
| 26 | + function: /\b[a-zA-Z_.][\w.]*(?=\s*\()/, | |
| 27 | + operator: /->?>?|<(?:=|<?-)?|[>=!]=?|::?|&&?|\|\|?|[+*\/^$@~]/, | |
| 28 | + punctuation: /[(){}\[\],;]/, | |
| 29 | + } | |
| 30 | +} | |
| 31 | +if (!Prism.languages.toml) { | |
| 32 | + Prism.languages.toml = { | |
| 33 | + comment: /#.*/, | |
| 34 | + table: { pattern: /^\s*\[[^\]]+\]/m, alias: 'keyword' }, | |
| 35 | + string: { pattern: /"(?:\\.|[^"\\])*"|'[^']*'/, greedy: true }, | |
| 36 | + key: { pattern: /^\s*[\w.-]+(?=\s*=)/m, alias: 'attr-name' }, | |
| 37 | + number: /\b\d[\d_]*(?:\.\d+)?\b/, | |
| 38 | + boolean: /\b(?:true|false)\b/, | |
| 39 | + punctuation: /[=[\]{},]/, | |
| 40 | + } | |
| 41 | +} | |
| 42 | +if (!Prism.languages.http) { | |
| 43 | + Prism.languages.http = { | |
| 44 | + 'request-line': { pattern: /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s.+/m, alias: 'keyword' }, | |
| 45 | + 'status-line': { pattern: /^HTTP\/[\d.]+\s+\d{3}.*/m, alias: 'keyword' }, | |
| 46 | + 'header-name': { pattern: /^[\w-]+(?=:)/m, alias: 'attr-name' }, | |
| 47 | + number: /\b\d+\b/, | |
| 48 | + punctuation: /:/, | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +const THEME = { | |
| 53 | + plain: { color: 'var(--code-fg)', backgroundColor: 'transparent' }, | |
| 54 | + styles: [ | |
| 55 | + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--code-comment)', fontStyle: 'italic' } }, | |
| 56 | + { types: ['punctuation', 'operator'], style: { color: 'var(--code-punct)' } }, | |
| 57 | + { types: ['property', 'attr-name', 'variable', 'flag', 'key', 'header-name'], style: { color: 'var(--code-key)' } }, | |
| 58 | + { types: ['string', 'char', 'url', 'inserted'], style: { color: 'var(--code-string)' } }, | |
| 59 | + { types: ['number', 'boolean', 'constant', 'symbol'], style: { color: 'var(--code-number)' } }, | |
| 60 | + { types: ['keyword', 'builtin', 'important', 'atrule', 'tag', 'table'], style: { color: 'var(--code-keyword)' } }, | |
| 61 | + { types: ['function', 'class-name', 'percent-operator'], style: { color: 'var(--code-func)' } }, | |
| 62 | + { types: ['deleted'], style: { color: 'var(--danger)' } }, | |
| 63 | + ], | |
| 64 | +} | |
| 65 | + | |
| 66 | +export const LANG_ALIASES = { curl: 'bash', sh: 'bash', shell: 'bash', js: 'javascript', ts: 'typescript', py: 'python', text: 'text', txt: 'text' } | |
| 67 | + | |
| 68 | +export default function CodeHighlight({ code, language, lineNumbers }) { | |
| 69 | + const lang = LANG_ALIASES[language] || language | |
| 70 | + const known = Prism.languages[lang] ? lang : 'text' | |
| 71 | + return ( | |
| 72 | + <Highlight code={code} language={known} theme={THEME}> | |
| 73 | + {({ tokens, getLineProps, getTokenProps }) => ( | |
| 74 | + <code> | |
| 75 | + {tokens.map((line, i) => ( | |
| 76 | + <span key={i} {...getLineProps({ line })} className="code-line"> | |
| 77 | + {lineNumbers && <span className="code-ln" aria-hidden="true">{i + 1}</span>} | |
| 78 | + {line.map((token, k) => <span key={k} {...getTokenProps({ token })} />)} | |
| 79 | + {'\n'} | |
| 80 | + </span> | |
| 81 | + ))} | |
| 82 | + </code> | |
| 83 | + )} | |
| 84 | + </Highlight> | |
| 85 | + ) | |
| 86 | +} | |
modified
hfmarketdata/web/src/components/CodeTabs.jsx
+3 −3
@@ -1,10 +1,10 @@ | ||
| 1 | 1 | // <CodeTabs> — one code block per language, driven by the global persisted language (LangSelector). |
| 2 | 2 | // snippets: { curl, python, javascript, r } (any subset) or an array [{ id, label, language, code }]. |
| 3 | 3 | import React from 'react' |
| 4 | −import Code from './Code.jsx' | |
| 4 | +import CodeBlock from './CodeBlock.jsx' | |
| 5 | 5 | import LangSelector, { LANGS, useLang } from './LangSelector.jsx' |
| 6 | 6 | |
| 7 | −export default function CodeTabs({ snippets, title, maxHeight, className = '' }) { | |
| 7 | +export default function CodeTabs({ snippets, title, maxHeight, className = '', footer, lineNumbers = false }) { | |
| 8 | 8 | const [lang, setLang] = useLang() |
| 9 | 9 | const items = Array.isArray(snippets) |
| 10 | 10 | ? snippets |
@@ -20,7 +20,7 @@ export default function CodeTabs({ snippets, title, maxHeight, className = '' }) | ||
| 20 | 20 | ? <div className="lang-selector lang-sm" role="radiogroup" aria-label="Code sample">{items.map(s => <button key={s.id} type="button" role="radio" aria-checked={s.id === value} className={`lang-btn ${s.id === value ? 'active' : ''}`} onClick={() => setLang(s.id)}>{s.label}</button>)}</div> |
| 21 | 21 | : <LangSelector value={value} onChange={setLang} />} |
| 22 | 22 | </div> |
| 23 | − <Code code={active.code} language={active.language} maxHeight={maxHeight} title={null} /> | |
| 23 | + <CodeBlock code={active.code} language={active.language} maxHeight={maxHeight} title={active.language === 'bash' ? 'shell' : active.language} lineNumbers={lineNumbers} footer={footer} /> | |
| 24 | 24 | </div> |
| 25 | 25 | ) |
| 26 | 26 | } |
added
hfmarketdata/web/src/components/CopyButton.jsx
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +// useCopy() + <CopyButton> — the single clipboard helper of the site (code blocks, URLs, keys, prompts). | |
| 2 | +import React, { useCallback, useEffect, useState } from 'react' | |
| 3 | +import { CheckIcon, CopyIcon } from './Icons.jsx' | |
| 4 | + | |
| 5 | +export function useCopy(timeout = 1600) { | |
| 6 | + const [copied, setCopied] = useState(false) | |
| 7 | + const copy = useCallback(async text => { | |
| 8 | + const value = typeof text === 'function' ? text() : text | |
| 9 | + try { | |
| 10 | + await navigator.clipboard.writeText(value) | |
| 11 | + } catch { | |
| 12 | + const ta = document.createElement('textarea'); ta.value = value; ta.setAttribute('readonly', '') | |
| 13 | + ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select() | |
| 14 | + try { document.execCommand('copy') } catch { /* blocked */ } finally { document.body.removeChild(ta) } | |
| 15 | + } | |
| 16 | + setCopied(true) | |
| 17 | + return true | |
| 18 | + }, []) | |
| 19 | + useEffect(() => { if (!copied) return undefined; const t = setTimeout(() => setCopied(false), timeout); return () => clearTimeout(t) }, [copied, timeout]) | |
| 20 | + return { copied, copy } | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * @param {string|() => string} text what to copy | |
| 25 | + * @param {string} [label='Copy'] visible label (also the aria-label when `iconOnly`) | |
| 26 | + * @param {'sm'|'md'} [size='sm'] | |
| 27 | + * @param {boolean} [iconOnly=false] | |
| 28 | + * @param {'ghost'|'secondary'} [variant='ghost'] | |
| 29 | + */ | |
| 30 | +export default function CopyButton({ text, label = 'Copy', copiedLabel = 'Copied', size = 'sm', iconOnly = false, variant = 'ghost', className = '', ...rest }) { | |
| 31 | + const { copied, copy } = useCopy() | |
| 32 | + return ( | |
| 33 | + <button type="button" className={`copy-btn copy-${size} copy-${variant} ${copied ? 'copied' : ''} ${iconOnly ? 'copy-icon-only' : ''} ${className}`} | |
| 34 | + onClick={() => copy(text)} aria-label={copied ? copiedLabel : label} title={label} aria-live="polite" {...rest}> | |
| 35 | + {copied ? <CheckIcon /> : <CopyIcon />}{!iconOnly && <span>{copied ? copiedLabel : label}</span>} | |
| 36 | + </button> | |
| 37 | + ) | |
| 38 | +} | |
added
hfmarketdata/web/src/components/Field.jsx
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +// Form primitives: <Field label hint error required> wraps one control and wires id / aria-describedby / | |
| 2 | +// aria-invalid; <Input>, <Select>, <Textarea> are thin styled wrappers that accept `invalid`. | |
| 3 | +import React, { cloneElement, isValidElement, useId } from 'react' | |
| 4 | + | |
| 5 | +export function Field({ label, hint, error, required = false, id, className = '', children, inline = false, labelExtra }) { | |
| 6 | + const auto = useId() | |
| 7 | + const fid = id || `f-${auto}` | |
| 8 | + const hintId = hint ? `${fid}-hint` : undefined | |
| 9 | + const errId = error ? `${fid}-err` : undefined | |
| 10 | + const described = [hintId, errId].filter(Boolean).join(' ') || undefined | |
| 11 | + const control = isValidElement(children) | |
| 12 | + ? cloneElement(children, { id: fid, 'aria-describedby': [children.props['aria-describedby'], described].filter(Boolean).join(' ') || undefined, 'aria-invalid': error ? true : children.props['aria-invalid'], 'aria-required': required || undefined }) | |
| 13 | + : children | |
| 14 | + return ( | |
| 15 | + <div className={`field ${inline ? 'field-inline' : ''} ${error ? 'field-error' : ''} ${className}`}> | |
| 16 | + {label && <label htmlFor={fid} className="field-label">{label}{required && <span className="field-req" aria-hidden="true">*</span>}{labelExtra && <span className="field-label-extra">{labelExtra}</span>}</label>} | |
| 17 | + {control} | |
| 18 | + {hint && <small id={hintId} className="field-hint">{hint}</small>} | |
| 19 | + {error && <small id={errId} className="field-err" role="alert">{error}</small>} | |
| 20 | + </div> | |
| 21 | + ) | |
| 22 | +} | |
| 23 | + | |
| 24 | +export const Input = React.forwardRef(function Input({ invalid, className = '', mono = false, ...props }, ref) { | |
| 25 | + return <input ref={ref} className={`input ${mono ? 'mono' : ''} ${invalid ? 'invalid' : ''} ${className}`.trim()} aria-invalid={invalid || props['aria-invalid'] || undefined} {...props} /> | |
| 26 | +}) | |
| 27 | + | |
| 28 | +export const Select = React.forwardRef(function Select({ invalid, className = '', options, children, ...props }, ref) { | |
| 29 | + return ( | |
| 30 | + <select ref={ref} className={`select ${invalid ? 'invalid' : ''} ${className}`.trim()} aria-invalid={invalid || props['aria-invalid'] || undefined} {...props}> | |
| 31 | + {options ? options.map(o => (typeof o === 'string' ? <option key={o} value={o}>{o === '' ? '(default)' : o}</option> : <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>)) : children} | |
| 32 | + </select> | |
| 33 | + ) | |
| 34 | +}) | |
| 35 | + | |
| 36 | +export const Textarea = React.forwardRef(function Textarea({ invalid, className = '', ...props }, ref) { | |
| 37 | + return <textarea ref={ref} className={`textarea ${invalid ? 'invalid' : ''} ${className}`.trim()} aria-invalid={invalid || props['aria-invalid'] || undefined} {...props} /> | |
| 38 | +}) | |
| 39 | + | |
| 40 | +export default Field | |
modified
hfmarketdata/web/src/components/Icons.jsx
+11 −0
@@ -28,3 +28,14 @@ export const TerminalIcon = p => <I {...p}><path d="m5 7 5 5-5 5M12 17h7" /></I> | ||
| 28 | 28 | export const EditIcon = p => <I {...p}><path d="M4 20h4l10.5-10.5a2 2 0 0 0 0-2.8l-1.2-1.2a2 2 0 0 0-2.8 0L4 16v4z" /></I> |
| 29 | 29 | export const GitIcon = p => <I {...p}><circle cx="6" cy="6" r="2.5" /><circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="8" r="2.5" /><path d="M6 8.5v7M18 10.5c0 3-3 4-6 4.5s-6 1-6 1" /></I> |
| 30 | 30 | export const PulseIcon = p => <I {...p}><path d="M3 12h4l3-7 4 14 3-7h4" /></I> |
| 31 | +export const WrapIcon = p => <I {...p}><path d="M4 6h16M4 12h11a3 3 0 0 1 0 6h-3M4 18h5M14 15l-2 3 2 3" /></I> | |
| 32 | +export const CheckCircleIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="m8.5 12 2.5 2.5L16 9" /></I> | |
| 33 | +export const InboxIcon = p => <I {...p}><path d="M4 13h4l2 3h4l2-3h4M5 5h14l2 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6z" /></I> | |
| 34 | +export const WifiOffIcon = p => <I {...p}><path d="M2 8.5a16 16 0 0 1 6-3.2M12 12.5a8 8 0 0 1 4 1.5M8.5 16a4 4 0 0 1 3.5-1M12 20h.01M3 3l18 18M9.5 5.4A16 16 0 0 1 22 8.5M5.5 12.5a12 12 0 0 1 3-1.7" /></I> | |
| 35 | +export const ClockIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></I> | |
| 36 | +export const ChartIcon = p => <I {...p}><path d="M4 19V5M4 19h16M8 15v-4M12 15V8M16 15v-2M20 15V6" /></I> | |
| 37 | +export const LinkIcon = p => <I {...p}><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" /></I> | |
| 38 | +export const ShieldIcon = p => <I {...p}><path d="M12 3 4 6v6c0 4.5 3.4 8 8 9 4.6-1 8-4.5 8-9V6z" /><path d="m9 12 2 2 4-4" /></I> | |
| 39 | +export const GlobeIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></I> | |
| 40 | +export const FileIcon = p => <I {...p}><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" /><path d="M14 3v5h5M9 13h6M9 17h6" /></I> | |
| 41 | +export const CompassIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="m15.5 8.5-2 5-5 2 2-5z" /></I> | |
deleted
hfmarketdata/web/src/components/IntCopyBlock.jsx
+0 −66
@@ -1,66 +0,0 @@ | ||
| 1 | −// Copy-in-one-click code block for the Integrations pages. Local to this area (web-core will ship a | |
| 2 | −// shared CodeBlock later); uses only theme.css tokens, no external deps, no CDN. | |
| 3 | −import React, { useCallback, useEffect, useState } from 'react' | |
| 4 | − | |
| 5 | −export function useCopy(timeoutMs = 1600) { | |
| 6 | − const [copied, setCopied] = useState(false) | |
| 7 | − useEffect(() => { | |
| 8 | − if (!copied) return undefined | |
| 9 | − const t = setTimeout(() => setCopied(false), timeoutMs) | |
| 10 | − return () => clearTimeout(t) | |
| 11 | − }, [copied, timeoutMs]) | |
| 12 | − const copy = useCallback(async (text) => { | |
| 13 | − try { | |
| 14 | − if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text) | |
| 15 | − else { | |
| 16 | − const ta = document.createElement('textarea') | |
| 17 | − ta.value = text | |
| 18 | − ta.setAttribute('readonly', '') | |
| 19 | − ta.style.position = 'fixed' | |
| 20 | − ta.style.opacity = '0' | |
| 21 | − document.body.appendChild(ta) | |
| 22 | − ta.select() | |
| 23 | − document.execCommand('copy') | |
| 24 | − document.body.removeChild(ta) | |
| 25 | − } | |
| 26 | − setCopied(true) | |
| 27 | − return true | |
| 28 | − } catch { | |
| 29 | − return false | |
| 30 | − } | |
| 31 | − }, []) | |
| 32 | − return { copied, copy } | |
| 33 | −} | |
| 34 | − | |
| 35 | −/** | |
| 36 | − * <IntCopyBlock code="…" lang="bash" title="Terminal" /> | |
| 37 | − * - `title` renders a small header with the language/filename and the copy button. | |
| 38 | − * - `lines` (array) may be used instead of `code`. | |
| 39 | − * - `note` renders a muted caption under the block. | |
| 40 | − */ | |
| 41 | −export default function IntCopyBlock({ code, lines, lang = 'text', title, note, compact = false }) { | |
| 42 | − const text = code ?? (lines || []).join('\n') | |
| 43 | − const { copied, copy } = useCopy() | |
| 44 | − return ( | |
| 45 | − <figure className={`int-copy${compact ? ' compact' : ''}`}> | |
| 46 | − <div className="int-copy-head"> | |
| 47 | − <span className="int-copy-title mono">{title || lang}</span> | |
| 48 | − <button type="button" className={`int-copy-btn${copied ? ' ok' : ''}`} onClick={() => copy(text)} aria-live="polite" aria-label={copied ? 'Copied' : 'Copy to clipboard'}> | |
| 49 | − {copied ? 'Copied' : 'Copy'} | |
| 50 | − </button> | |
| 51 | − </div> | |
| 52 | − <pre className="int-copy-pre" data-lang={lang}><code>{text}</code></pre> | |
| 53 | − {note ? <figcaption className="int-copy-note muted">{note}</figcaption> : null} | |
| 54 | − </figure> | |
| 55 | − ) | |
| 56 | −} | |
| 57 | − | |
| 58 | −/** Inline copyable chip, e.g. for a single command or an env var. */ | |
| 59 | −export function IntCopyChip({ text, label }) { | |
| 60 | − const { copied, copy } = useCopy() | |
| 61 | − return ( | |
| 62 | − <button type="button" className={`int-chip mono${copied ? ' ok' : ''}`} onClick={() => copy(text)} title="Copy"> | |
| 63 | − {label || text}<span className="int-chip-ico" aria-hidden="true">{copied ? ' ✓' : ' ⧉'}</span> | |
| 64 | − </button> | |
| 65 | − ) | |
| 66 | −} | |
modified
hfmarketdata/web/src/components/Markdown.jsx
+1 −1
@@ -3,7 +3,7 @@ | ||
| 3 | 3 | // GFM tables, horizontal rules; inline code, **bold**, *italic*, [links](href), autolinks. Unsafe URL schemes are dropped. |
| 4 | 4 | import React from 'react' |
| 5 | 5 | import { Link } from 'react-router-dom' |
| 6 | −import Code from './Code.jsx' | |
| 6 | +import Code from './CodeBlock.jsx' | |
| 7 | 7 | |
| 8 | 8 | const SAFE_HREF = /^(https?:|mailto:|\/|#|\.\.?\/)/i |
| 9 | 9 | |
modified
hfmarketdata/web/src/components/PgCallout.jsx
+4 −14
@@ -1,14 +1,4 @@ | ||
| 1 | −import React from 'react' | |
| 2 | − | |
| 3 | −// kind: info | warn | danger | success | |
| 4 | −export default function PgCallout({ kind = 'info', title, children, action = null, className = '' }) { | |
| 5 | − return ( | |
| 6 | − <div role={kind === 'danger' ? 'alert' : 'status'} className={`pg-callout pg-callout-${kind} ${className}`}> | |
| 7 | − <div className="pg-callout-body"> | |
| 8 | − {title && <strong className="pg-callout-title">{title}</strong>} | |
| 9 | − <div>{children}</div> | |
| 10 | − </div> | |
| 11 | − {action && <div className="pg-callout-action">{action}</div>} | |
| 12 | − </div> | |
| 13 | − ) | |
| 14 | −} | |
| 1 | +// Compatibility alias — the playground callout is now the shared <Callout>. Kept because dashboard/auth/admin pages | |
| 2 | +// import this path (`kind` prop maps to `type`). New code should import Callout from './Callout.jsx'. | |
| 3 | +import Callout from './Callout.jsx' | |
| 4 | +export default Callout | |
deleted
hfmarketdata/web/src/components/PgCodeBlock.jsx
+0 −28
@@ -1,28 +0,0 @@ | ||
| 1 | −// Syntax-highlighted code block with copy button (prism-react-renderer, bundled — no CDN). | |
| 2 | −import React from 'react' | |
| 3 | −import { Highlight, themes } from 'prism-react-renderer' | |
| 4 | −import PgCopyButton from './PgCopyButton.jsx' | |
| 5 | − | |
| 6 | −const LANG = { curl: 'bash', bash: 'bash', python: 'python', javascript: 'javascript', js: 'javascript', json: 'json' } | |
| 7 | − | |
| 8 | −export default function PgCodeBlock({ code, language = 'bash', title, maxHeight = 360 }) { | |
| 9 | − return ( | |
| 10 | − <div className="pg-code"> | |
| 11 | − <div className="pg-code-head"> | |
| 12 | − <span className="pg-code-title mono">{title || language}</span> | |
| 13 | − <PgCopyButton text={code} small /> | |
| 14 | − </div> | |
| 15 | − <Highlight code={code} language={LANG[language] || language} theme={themes.nightOwl}> | |
| 16 | − {({ className, tokens, getLineProps, getTokenProps }) => ( | |
| 17 | − <pre className={`${className} pg-code-pre`} style={{ maxHeight }} tabIndex={0}> | |
| 18 | − {tokens.map((line, i) => ( | |
| 19 | − <div key={i} {...getLineProps({ line })}> | |
| 20 | − {line.map((token, k) => <span key={k} {...getTokenProps({ token })} />)} | |
| 21 | − </div> | |
| 22 | − ))} | |
| 23 | − </pre> | |
| 24 | − )} | |
| 25 | − </Highlight> | |
| 26 | − </div> | |
| 27 | − ) | |
| 28 | −} | |
modified
hfmarketdata/web/src/components/PgCopyButton.jsx
+6 −12
@@ -1,14 +1,8 @@ | ||
| 1 | −import React, { useEffect, useState } from 'react' | |
| 1 | +// Compatibility alias — the playground copy button is now the shared <CopyButton>. Kept because dashboard/admin pages | |
| 2 | +// import this path with `{ text, label, small }`. New code should import CopyButton from './CopyButton.jsx'. | |
| 3 | +import React from 'react' | |
| 4 | +import CopyButton from './CopyButton.jsx' | |
| 2 | 5 | |
| 3 | −export default function PgCopyButton({ text, label = 'Copy', className = '', small = false }) { | |
| 4 | − const [done, setDone] = useState(false) | |
| 5 | − useEffect(() => { if (!done) return; const t = setTimeout(() => setDone(false), 1500); return () => clearTimeout(t) }, [done]) | |
| 6 | − const copy = async () => { | |
| 7 | − try { await navigator.clipboard.writeText(typeof text === 'function' ? text() : text); setDone(true) } catch { /* clipboard blocked */ } | |
| 8 | − } | |
| 9 | − return ( | |
| 10 | − <button type="button" className={`btn ${small ? 'btn-sm' : ''} ${className}`} onClick={copy} aria-live="polite"> | |
| 11 | − {done ? 'Copied' : label} | |
| 12 | − </button> | |
| 13 | − ) | |
| 6 | +export default function PgCopyButton({ text, label = 'Copy', small = false, className = '' }) { | |
| 7 | + return <CopyButton text={text} label={label} size={small ? 'sm' : 'md'} variant="secondary" className={className} /> | |
| 14 | 8 | } |
deleted
hfmarketdata/web/src/components/PgTabs.jsx
+0 −35
@@ -1,35 +0,0 @@ | ||
| 1 | −// Minimal accessible tabs (roving tabindex). Swappable with web-core's shared `Tabs` later. | |
| 2 | −import React, { useId } from 'react' | |
| 3 | − | |
| 4 | −export default function PgTabs({ tabs, value, onChange, label = 'Tabs', children, right = null, className = '' }) { | |
| 5 | − const id = useId() | |
| 6 | − const idx = Math.max(0, tabs.findIndex(t => t.id === value)) | |
| 7 | − const onKey = e => { | |
| 8 | − if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return | |
| 9 | − e.preventDefault() | |
| 10 | − let next = idx | |
| 11 | − if (e.key === 'ArrowLeft') next = (idx - 1 + tabs.length) % tabs.length | |
| 12 | − if (e.key === 'ArrowRight') next = (idx + 1) % tabs.length | |
| 13 | − if (e.key === 'Home') next = 0 | |
| 14 | − if (e.key === 'End') next = tabs.length - 1 | |
| 15 | − onChange(tabs[next].id) | |
| 16 | − document.getElementById(`${id}-tab-${tabs[next].id}`)?.focus() | |
| 17 | − } | |
| 18 | − return ( | |
| 19 | − <div className={`pg-tabs ${className}`}> | |
| 20 | − <div className="pg-tablist-row"> | |
| 21 | − <div role="tablist" aria-label={label} className="pg-tablist" onKeyDown={onKey}> | |
| 22 | − {tabs.map(t => ( | |
| 23 | − <button key={t.id} id={`${id}-tab-${t.id}`} role="tab" type="button" aria-selected={t.id === value} | |
| 24 | − aria-controls={`${id}-panel`} tabIndex={t.id === value ? 0 : -1} disabled={t.disabled} | |
| 25 | − className={`pg-tab ${t.id === value ? 'active' : ''}`} onClick={() => onChange(t.id)}> | |
| 26 | − {t.label}{t.badge != null && <span className="pg-tab-badge">{t.badge}</span>} | |
| 27 | − </button> | |
| 28 | − ))} | |
| 29 | − </div> | |
| 30 | − {right} | |
| 31 | − </div> | |
| 32 | − <div role="tabpanel" id={`${id}-panel`} className="pg-tabpanel">{children}</div> | |
| 33 | − </div> | |
| 34 | − ) | |
| 35 | −} | |
modified
hfmarketdata/web/src/components/SearchDialog.jsx
+4 −4
@@ -15,8 +15,8 @@ async function getIndex() { | ||
| 15 | 15 | return indexMemo |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | −export default function SearchDialog({ open, onClose }) { | |
| 19 | − const [q, setQ] = useState('') | |
| 18 | +export default function SearchDialog({ open, onClose, initialQuery = '' }) { | |
| 19 | + const [q, setQ] = useState(initialQuery) | |
| 20 | 20 | const [items, setItems] = useState(indexMemo || []) |
| 21 | 21 | const [sel, setSel] = useState(0) |
| 22 | 22 | const inputRef = useRef(null) |
@@ -25,13 +25,13 @@ export default function SearchDialog({ open, onClose }) { | ||
| 25 | 25 | |
| 26 | 26 | useEffect(() => { |
| 27 | 27 | if (!open) return |
| 28 | − setQ(''); setSel(0) | |
| 28 | + setQ(initialQuery || ''); setSel(0) | |
| 29 | 29 | getIndex().then(setItems) |
| 30 | 30 | const t = setTimeout(() => inputRef.current?.focus(), 10) |
| 31 | 31 | const prev = document.body.style.overflow |
| 32 | 32 | document.body.style.overflow = 'hidden' |
| 33 | 33 | return () => { clearTimeout(t); document.body.style.overflow = prev } |
| 34 | − }, [open]) | |
| 34 | + }, [open, initialQuery]) | |
| 35 | 35 | |
| 36 | 36 | const results = useMemo(() => search(items, q), [items, q]) |
| 37 | 37 | const groups = useMemo(() => groupResults(results), [results]) |
added
hfmarketdata/web/src/components/States.jsx
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +// Loading / empty / error states + Stat tile. ErrorState understands network errors, 4xx, 429 (Retry-After countdown | |
| 2 | +// with a free-account CTA) and 5xx; the countdown helpers are shared with the playground rate-limit panel. | |
| 3 | +import React, { useEffect, useState } from 'react' | |
| 4 | +import { Link } from 'react-router-dom' | |
| 5 | +import { InboxIcon, WarnIcon, WifiOffIcon } from './Icons.jsx' | |
| 6 | + | |
| 7 | +/** <Skeleton width height /> inline shimmer; <Skeleton lines={3} /> paragraph placeholder. */ | |
| 8 | +export function Skeleton({ width, height, lines, block = false, className = '', style }) { | |
| 9 | + if (lines) { | |
| 10 | + return <div className={`skeleton-lines ${className}`} aria-hidden="true">{Array.from({ length: lines }, (_, i) => <span key={i} className="skeleton skeleton-block skeleton-text" style={{ width: i === lines - 1 ? '70%' : '100%' }} />)}</div> | |
| 11 | + } | |
| 12 | + return <span className={`skeleton ${block ? 'skeleton-block' : ''} ${className}`} style={{ width, height, ...style }} aria-hidden="true" /> | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function EmptyState({ icon, title = 'Nothing here yet', children, actions, className = '', ...rest }) { | |
| 16 | + const Icon = icon || InboxIcon | |
| 17 | + return ( | |
| 18 | + <div className={`empty-state ${className}`} {...rest}> | |
| 19 | + <Icon /> | |
| 20 | + <h3>{title}</h3> | |
| 21 | + {children && <p>{children}</p>} | |
| 22 | + {actions && <div className="actions">{actions}</div>} | |
| 23 | + </div> | |
| 24 | + ) | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function useCountdown(untilEpochSec) { | |
| 28 | + const [left, setLeft] = useState(() => (untilEpochSec ? Math.max(0, untilEpochSec - Math.floor(Date.now() / 1000)) : 0)) | |
| 29 | + useEffect(() => { | |
| 30 | + if (!untilEpochSec) { setLeft(0); return undefined } | |
| 31 | + const tick = () => setLeft(Math.max(0, untilEpochSec - Math.floor(Date.now() / 1000))) | |
| 32 | + tick() | |
| 33 | + const t = setInterval(tick, 1000) | |
| 34 | + return () => clearInterval(t) | |
| 35 | + }, [untilEpochSec]) | |
| 36 | + return left | |
| 37 | +} | |
| 38 | + | |
| 39 | +export const fmtDuration = s => (s >= 3600 ? `${Math.floor(s / 3600)} h ${Math.floor((s % 3600) / 60)} min` : s >= 60 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${s} s`) | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * @param {number} [status] HTTP status (0 = network) | |
| 43 | + * @param {string} [code] API error code | |
| 44 | + * @param {string} [message] | |
| 45 | + * @param {number} [retryUntil] epoch seconds when a 429 lifts (drives the countdown) | |
| 46 | + * @param {boolean} [authenticated] hides the free-account CTA on 429 | |
| 47 | + * @param {string} [docs] error.docs URL | |
| 48 | + * @param {() => void} [onRetry] | |
| 49 | + */ | |
| 50 | +export function ErrorState({ status = 0, code, message, title, retryUntil, authenticated = false, docs, onRetry, actions, children, className = '', ...rest }) { | |
| 51 | + const left = useCountdown(status === 429 ? retryUntil || null : null) | |
| 52 | + const network = !status | |
| 53 | + const limited = status === 429 | |
| 54 | + const Icon = network ? WifiOffIcon : WarnIcon | |
| 55 | + const heading = title || (network ? 'Network error' : limited ? 'Rate limited — not down' : status >= 500 ? `Server error · HTTP ${status}` : `${code || 'Error'} · HTTP ${status}`) | |
| 56 | + return ( | |
| 57 | + <div className={`error-state ${limited ? 'is-429' : ''} ${className}`} role="alert" {...rest}> | |
| 58 | + <Icon /> | |
| 59 | + <h3>{heading}</h3> | |
| 60 | + {limited && retryUntil ? <div className="countdown" aria-live="polite">{left > 0 ? fmtDuration(left) : 'you can retry now'}</div> : null} | |
| 61 | + {message && <p>{message}</p>} | |
| 62 | + {children} | |
| 63 | + <div className="actions"> | |
| 64 | + {limited && !authenticated && <Link to="/signup" className="btn btn-primary btn-sm">Create free account · 120 req/min</Link>} | |
| 65 | + {onRetry && <button type="button" className="btn btn-sm" onClick={onRetry} disabled={limited && left > 0}>Retry</button>} | |
| 66 | + {docs && <a className="btn btn-ghost btn-sm" href={docs}>Docs</a>} | |
| 67 | + {actions} | |
| 68 | + </div> | |
| 69 | + </div> | |
| 70 | + ) | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** <Stat label value hint delta /> — number tile. `delta` ≥ 0 renders in --up, < 0 in --down. */ | |
| 74 | +export function Stat({ label, value, hint, delta, loading = false, className = '', ...rest }) { | |
| 75 | + return ( | |
| 76 | + <div className={`stat ${className}`} {...rest}> | |
| 77 | + <span className="stat-label">{label}</span> | |
| 78 | + <span className="stat-value">{loading ? <Skeleton width="60%" height="1em" /> : value}</span> | |
| 79 | + {(hint || delta != null) && ( | |
| 80 | + <span className="stat-hint"> | |
| 81 | + {delta != null && <span className={`stat-delta ${delta >= 0 ? 'up' : 'down'}`}>{delta >= 0 ? '+' : ''}{delta}</span>} | |
| 82 | + {delta != null && hint ? ' · ' : ''}{hint} | |
| 83 | + </span> | |
| 84 | + )} | |
| 85 | + </div> | |
| 86 | + ) | |
| 87 | +} | |
modified
hfmarketdata/web/src/components/Table.jsx
+10 −4
@@ -1,9 +1,11 @@ | ||
| 1 | −// <Table columns rows> — responsive data table. columns: [{ key, label, align?: 'right', render?(row) }]. | |
| 1 | +// <Table columns rows> — data table. columns: [{ key, label, align?: 'right', render?(row), width?, mono? }]. | |
| 2 | +// `dense` tighter rows · `stickyFirst` pins the first column when scrolling horizontally · `stack` turns rows into | |
| 3 | +// label/value pairs under 640 px (cells carry data-label) · header is sticky inside `.table-wrap` by default. | |
| 2 | 4 | import React from 'react' |
| 3 | 5 | |
| 4 | −export default function Table({ columns, rows, caption, keyField, dense = false, className = '', emptyText = 'No rows' }) { | |
| 6 | +export default function Table({ columns, rows, caption, keyField, dense = false, stickyFirst = false, stack = false, maxHeight, className = '', emptyText = 'No rows', ...rest }) { | |
| 5 | 7 | return ( |
| 6 | − <div className={`table-wrap ${dense ? 'dense' : ''} ${className}`}> | |
| 8 | + <div className={`table-wrap ${dense ? 'dense' : ''} ${stickyFirst ? 'sticky-first' : ''} ${stack ? 'table-stack' : ''} ${className}`} style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined} {...rest}> | |
| 7 | 9 | <table> |
| 8 | 10 | {caption && <caption className="sr-only">{caption}</caption>} |
| 9 | 11 | <thead> |
@@ -13,7 +15,11 @@ export default function Table({ columns, rows, caption, keyField, dense = false, | ||
| 13 | 15 | {rows.length === 0 && <tr><td colSpan={columns.length} className="muted">{emptyText}</td></tr>} |
| 14 | 16 | {rows.map((r, i) => ( |
| 15 | 17 | <tr key={keyField ? r[keyField] : i}> |
| 16 | − {columns.map(c => <td key={c.key} className={c.align === 'right' ? 'num' : ''}>{c.render ? c.render(r) : r[c.key]}</td>)} | |
| 18 | + {columns.map(c => ( | |
| 19 | + <td key={c.key} className={`${c.align === 'right' ? 'num' : ''} ${c.mono ? 'mono' : ''}`.trim()} data-label={typeof c.label === 'string' ? c.label : undefined}> | |
| 20 | + {c.render ? c.render(r) : r[c.key]} | |
| 21 | + </td> | |
| 22 | + ))} | |
| 17 | 23 | </tr> |
| 18 | 24 | ))} |
| 19 | 25 | </tbody> |
modified
hfmarketdata/web/src/components/Tabs.jsx
+23 −18
@@ -1,9 +1,10 @@ | ||
| 1 | −// <Tabs> — accessible tab list (roving focus, arrow keys). Controlled or uncontrolled. | |
| 2 | −// items: [{ id, label, content?: ReactNode }] · value/onChange optional · renders children(activeId) when no content. | |
| 1 | +// <Tabs> — accessible tab list (roving focus, arrow keys). Controlled (`value` + `onChange`) or uncontrolled. | |
| 2 | +// items: [{ id, label, content?, icon?, badge?, disabled? }] · `right` = slot at the end of the tab bar · | |
| 3 | +// panel content = item.content, else children(activeId), else children. | |
| 3 | 4 | import React, { useId, useRef, useState } from 'react' |
| 4 | 5 | |
| 5 | −export default function Tabs({ items, value, onChange, defaultValue, size = 'md', className = '', children, ariaLabel = 'Tabs' }) { | |
| 6 | − const [inner, setInner] = useState(defaultValue ?? items[0]?.id) | |
| 6 | +export default function Tabs({ items, value, onChange, defaultValue, size = 'md', className = '', children, right = null, ariaLabel = 'Tabs', panel = true }) { | |
| 7 | + const [inner, setInner] = useState(defaultValue ?? items.find(t => !t.disabled)?.id) | |
| 7 | 8 | const active = value ?? inner |
| 8 | 9 | const set = id => { setInner(id); onChange?.(id) } |
| 9 | 10 | const base = useId() |
@@ -11,27 +12,31 @@ export default function Tabs({ items, value, onChange, defaultValue, size = 'md' | ||
| 11 | 12 | const onKey = (e, i) => { |
| 12 | 13 | const n = items.length |
| 13 | 14 | let j = null |
| 14 | − if (e.key === 'ArrowRight') j = (i + 1) % n | |
| 15 | − else if (e.key === 'ArrowLeft') j = (i - 1 + n) % n | |
| 16 | − else if (e.key === 'Home') j = 0 | |
| 17 | − else if (e.key === 'End') j = n - 1 | |
| 15 | + const step = dir => { let k = i; for (let s = 0; s < n; s++) { k = (k + dir + n) % n; if (!items[k].disabled) return k } return i } | |
| 16 | + if (e.key === 'ArrowRight') j = step(1) | |
| 17 | + else if (e.key === 'ArrowLeft') j = step(-1) | |
| 18 | + else if (e.key === 'Home') j = items.findIndex(t => !t.disabled) | |
| 19 | + else if (e.key === 'End') { for (let k = n - 1; k >= 0; k--) if (!items[k].disabled) { j = k; break } } | |
| 18 | 20 | if (j == null) return |
| 19 | 21 | e.preventDefault(); set(items[j].id); refs.current[j]?.focus() |
| 20 | 22 | } |
| 21 | 23 | const current = items.find(t => t.id === active) || items[0] |
| 22 | 24 | return ( |
| 23 | 25 | <div className={`tabs tabs-${size} ${className}`}> |
| 24 | − <div role="tablist" aria-label={ariaLabel} className="tablist"> | |
| 25 | − {items.map((t, i) => ( | |
| 26 | − <button key={t.id} ref={el => (refs.current[i] = el)} role="tab" type="button" id={`${base}-tab-${t.id}`} | |
| 27 | − aria-selected={t.id === active} aria-controls={`${base}-panel-${t.id}`} tabIndex={t.id === active ? 0 : -1} | |
| 28 | − className={`tab ${t.id === active ? 'active' : ''}`} onClick={() => set(t.id)} onKeyDown={e => onKey(e, i)}> | |
| 29 | − {t.icon}{t.label} | |
| 30 | − </button> | |
| 31 | − ))} | |
| 26 | + <div className="tabs-bar"> | |
| 27 | + <div role="tablist" aria-label={ariaLabel} className="tablist"> | |
| 28 | + {items.map((t, i) => ( | |
| 29 | + <button key={t.id} ref={el => (refs.current[i] = el)} role="tab" type="button" id={`${base}-tab-${t.id}`} | |
| 30 | + aria-selected={t.id === active} aria-controls={panel ? `${base}-panel` : undefined} tabIndex={t.id === active ? 0 : -1} disabled={t.disabled} | |
| 31 | + className={`tab ${t.id === active ? 'active' : ''}`} onClick={() => set(t.id)} onKeyDown={e => onKey(e, i)}> | |
| 32 | + {t.icon}{t.label}{t.badge != null && <span className="tab-badge">{t.badge}</span>} | |
| 33 | + </button> | |
| 34 | + ))} | |
| 35 | + </div> | |
| 36 | + {right && <div className="tabs-right">{right}</div>} | |
| 32 | 37 | </div> |
| 33 | − {current && ( | |
| 34 | − <div role="tabpanel" id={`${base}-panel-${current.id}`} aria-labelledby={`${base}-tab-${current.id}`} className="tabpanel"> | |
| 38 | + {panel && current && ( | |
| 39 | + <div role="tabpanel" id={`${base}-panel`} aria-labelledby={`${base}-tab-${current.id}`} className="tabpanel"> | |
| 35 | 40 | {current.content ?? (typeof children === 'function' ? children(current.id) : children)} |
| 36 | 41 | </div> |
| 37 | 42 | )} |
modified
hfmarketdata/web/src/components/components.css
+88 −36
@@ -1,77 +1,129 @@ | ||
| 1 | −/* Shared components: Code, Tabs, Callout, Badge, LangSelector, SearchDialog, Markdown prose. Owner: web-core. */ | |
| 1 | +/* Shared components: CodeBlock, CopyButton, Tabs, Callout, Badge, Field, LangSelector, SearchDialog, Markdown, | |
| 2 | + brand logos, stacked tables. Owner: web-core. Tokens only — no raw colours. */ | |
| 2 | 3 | |
| 3 | −/* Code */ | |
| 4 | −.code { border: 1px solid var(--line); border-radius: var(--radius); background: var(--code-bg); overflow: hidden; margin: 0 0 16px; min-width: 0; max-width: 100%; } | |
| 5 | −.code-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--line); background: var(--bg-1); background: color-mix(in srgb, var(--bg-1) 70%, var(--code-bg)); min-height: 34px; flex-wrap: wrap; } | |
| 6 | −.code-title { font-family: var(--mono); font-size: 12px; color: var(--fg-2); letter-spacing: .02em; } | |
| 7 | −/* contain: inline-size → the (scrolling) pre never inflates the intrinsic width of grid/flex ancestors on narrow screens */ | |
| 8 | −.code-pre { margin: 0; border: 0; border-radius: 0; background: transparent; padding: 12px 14px; font-size: 13px; line-height: 1.55; tab-size: 2; contain: inline-size; } | |
| 4 | +/* Code block */ | |
| 5 | +.code { border: 1px solid var(--line); border-radius: var(--r-md); background: var(--code-bg); overflow: hidden; margin: 0 0 16px; min-width: 0; max-width: 100%; } | |
| 6 | +.code-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 2px 4px 2px 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg-1) 70%, var(--code-bg)); min-height: 40px; } | |
| 7 | +.code-title { font-family: var(--mono); font-size: var(--fs-0); color: var(--fg-2); letter-spacing: .02em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } | |
| 8 | +.code-actions { display: inline-flex; align-items: center; gap: 2px; flex: none; } | |
| 9 | +/* contain: inline-size → the (scrolling) pre never inflates the intrinsic width of grid/flex ancestors */ | |
| 10 | +.code-pre { margin: 0; border: 0; border-radius: 0; background: transparent; padding: 12px 14px; font-size: var(--fs-1); line-height: 1.6; tab-size: 2; contain: inline-size; overflow: auto; } | |
| 11 | +.code-pre code { font-size: inherit; } | |
| 9 | 12 | .code-line { display: block; white-space: pre; } |
| 13 | +.code-wrap .code-line { white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 10 | 14 | .code-ln { display: inline-block; width: 2.4em; color: var(--fg-3); user-select: none; text-align: right; padding-right: 1.2em; } |
| 11 | −.copy-btn { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border-radius: 6px; border: 1px solid transparent; background: transparent; color: var(--fg-2); cursor: pointer; font-size: 12px; } | |
| 15 | +.code-foot { padding: 6px 12px 8px; border-top: 1px solid var(--line); font-size: var(--fs-0); color: var(--fg-2); } | |
| 16 | +.code-foot p { margin: 0; } | |
| 17 | +.copy-btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: var(--tap); min-width: var(--tap); padding: 0 8px; border-radius: var(--r-sm); border: 1px solid transparent; background: transparent; color: var(--fg-2); cursor: pointer; font-size: var(--fs-0); font-weight: 500; white-space: nowrap; } | |
| 12 | 18 | .copy-btn:hover { background: var(--bg-2); color: var(--fg); border-color: var(--line-2); } |
| 13 | 19 | .copy-btn.copied { color: var(--accent); } |
| 14 | 20 | .copy-btn svg { width: 14px; height: 14px; } |
| 21 | +.copy-md { min-height: var(--tap); padding: 0 12px; font-size: var(--fs-1); } | |
| 22 | +.copy-md svg { width: 16px; height: 16px; } | |
| 23 | +.copy-secondary { border-color: var(--line-2); background: var(--bg-1); color: var(--fg-1); } | |
| 24 | +.copy-icon-only { padding: 0; width: var(--tap); } | |
| 25 | +.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid color-mix(in srgb, currentColor 30%, transparent); border-top-color: currentColor; border-radius: 50%; animation: spin .8s linear infinite; } | |
| 26 | +@keyframes spin { to { transform: rotate(360deg); } } | |
| 15 | 27 | |
| 16 | 28 | /* Tabs */ |
| 17 | −.tablist { display: flex; gap: 2px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; } | |
| 29 | +.tabs { min-width: 0; } | |
| 30 | +.tabs-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--line); } | |
| 31 | +.tablist { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; } | |
| 18 | 32 | .tablist::-webkit-scrollbar { display: none; } |
| 19 | −.tab { position: relative; padding: 9px 12px; border: 0; background: transparent; color: var(--fg-2); cursor: pointer; font-weight: 500; font-size: 14px; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; } | |
| 20 | −.tab:hover { color: var(--fg); } | |
| 33 | +.tab { position: relative; min-height: 40px; padding: 0 12px; border: 0; background: transparent; color: var(--fg-2); cursor: pointer; font-weight: 500; font-size: var(--fs-2); white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; border-radius: var(--r-sm) var(--r-sm) 0 0; } | |
| 34 | +.tab:hover:not(:disabled) { color: var(--fg); } | |
| 21 | 35 | .tab.active { color: var(--fg); } |
| 22 | 36 | .tab.active::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--accent); border-radius: 2px; } |
| 23 | −.tabs-sm .tab { padding: 6px 10px; font-size: 13px; } | |
| 24 | −.tabpanel { padding-top: 14px; } | |
| 37 | +.tab:disabled { opacity: .45; cursor: not-allowed; } | |
| 38 | +.tab-badge { font-family: var(--mono); font-size: var(--fs-0); background: var(--bg-2); padding: 0 6px; border-radius: 10px; color: var(--fg-1); } | |
| 39 | +.tabs-sm .tab { min-height: 34px; padding: 0 10px; font-size: var(--fs-1); } | |
| 40 | +.tabs-right { display: flex; align-items: center; gap: 6px; flex: none; padding: 2px 4px; } | |
| 41 | +.tabpanel { padding-top: 14px; min-width: 0; } | |
| 42 | +.tabs-flush .tabpanel { padding-top: 0; } | |
| 43 | +@media (max-width: 960px) { .tab { min-height: 44px; } } | |
| 25 | 44 | |
| 26 | 45 | /* Language selector */ |
| 27 | −.lang-selector { display: inline-flex; gap: 2px; padding: 2px; border-radius: 7px; background: var(--bg-2); border: 1px solid var(--line); } | |
| 28 | −.lang-btn { border: 0; background: transparent; color: var(--fg-2); font-size: 12.5px; padding: 3px 9px; border-radius: 5px; cursor: pointer; font-weight: 500; } | |
| 46 | +.lang-selector { display: inline-flex; gap: 2px; padding: 2px; border-radius: var(--r-sm); background: var(--bg-2); border: 1px solid var(--line); } | |
| 47 | +.lang-btn { border: 0; background: transparent; color: var(--fg-2); font-size: var(--fs-0); min-height: calc(var(--tap) - 4px); min-width: 36px; padding: 0 10px; border-radius: 5px; cursor: pointer; font-weight: 500; } | |
| 29 | 48 | .lang-btn:hover { color: var(--fg); } |
| 30 | −.lang-btn.active { background: var(--bg); color: var(--fg); box-shadow: 0 1px 2px rgba(0, 0, 0, .25); } | |
| 49 | +.lang-btn.active { background: var(--bg); color: var(--fg); box-shadow: var(--shadow-1); } | |
| 31 | 50 | .codetabs { margin: 0 0 16px; min-width: 0; max-width: 100%; } |
| 32 | 51 | .codetabs-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } |
| 33 | 52 | .codetabs .code { margin-bottom: 0; } |
| 34 | 53 | |
| 35 | 54 | /* Callout */ |
| 36 | −.callout { display: flex; gap: 12px; padding: 14px 16px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--bg-1); margin: 0 0 18px; border-left-width: 3px; } | |
| 55 | +.callout { display: flex; gap: 12px; padding: 14px 16px; border-radius: var(--r-md); border: 1px solid var(--line); background: var(--bg-1); margin: 0 0 18px; border-left-width: 3px; align-items: flex-start; flex-wrap: wrap; } | |
| 37 | 56 | .callout-icon { flex: none; display: inline-grid; place-items: center; width: 22px; height: 22px; margin-top: 1px; } |
| 38 | 57 | .callout-icon svg { width: 18px; height: 18px; } |
| 39 | −.callout-body { min-width: 0; overflow-wrap: anywhere; } | |
| 40 | −.callout-title { font-weight: 650; margin-bottom: 3px; } | |
| 58 | +.callout-body { min-width: 0; flex: 1 1 240px; overflow-wrap: anywhere; font-size: var(--fs-2); } | |
| 59 | +.callout-title { font-weight: 650; margin-bottom: 3px; color: var(--fg); font-size: var(--fs-3); } | |
| 41 | 60 | .callout-body p:last-child { margin-bottom: 0; } |
| 42 | −.callout-info { border-left-color: var(--accent-2); } .callout-info .callout-icon { color: var(--accent-2); } | |
| 61 | +.callout-action { flex: none; display: flex; align-items: center; gap: 8px; align-self: center; } | |
| 62 | +.callout-compact { padding: 10px 12px; } | |
| 63 | +.callout-info { border-left-color: var(--info); } .callout-info .callout-icon { color: var(--info); } | |
| 43 | 64 | .callout-tip { border-left-color: var(--accent); } .callout-tip .callout-icon { color: var(--accent); } |
| 44 | −.callout-warning { border-left-color: var(--warn); background: var(--warn-soft); } .callout-warning .callout-icon { color: var(--warn); } | |
| 45 | −.callout-danger { border-left-color: var(--danger); background: var(--danger-soft); } .callout-danger .callout-icon { color: var(--danger); } | |
| 65 | +.callout-success { border-left-color: var(--success); background: color-mix(in srgb, var(--success) 6%, var(--bg-1)); } .callout-success .callout-icon { color: var(--success); } | |
| 66 | +.callout-warning { border-left-color: var(--warn); background: color-mix(in srgb, var(--warn) 7%, var(--bg-1)); } .callout-warning .callout-icon { color: var(--warn); } | |
| 67 | +.callout-danger { border-left-color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, var(--bg-1)); } .callout-danger .callout-icon { color: var(--danger); } | |
| 46 | 68 | |
| 47 | 69 | /* Badges */ |
| 48 | −.badge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--line-2); color: var(--fg-1); background: var(--bg-2); line-height: 1.6; white-space: nowrap; } | |
| 49 | −.badge-success { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: var(--accent-soft); } | |
| 50 | −.badge-info { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 40%, transparent); background: var(--accent-2-soft); } | |
| 70 | +.badge { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 999px; font-size: var(--fs-0); font-weight: 600; border: 1px solid var(--line-2); color: var(--fg-1); background: var(--bg-2); line-height: 1.6; white-space: nowrap; } | |
| 71 | +.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } | |
| 72 | +.badge-success { color: var(--success); border-color: color-mix(in srgb, var(--success) 40%, transparent); background: var(--success-soft); } | |
| 73 | +.badge-info { color: var(--info); border-color: color-mix(in srgb, var(--info) 40%, transparent); background: var(--info-soft); } | |
| 51 | 74 | .badge-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); } |
| 52 | 75 | .badge-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); background: var(--danger-soft); } |
| 76 | +.badge-tier { text-transform: uppercase; letter-spacing: .06em; font-size: 11px; } | |
| 53 | 77 | .method { display: inline-block; font-family: var(--mono); font-size: 11px; font-weight: 700; letter-spacing: .04em; padding: 2px 7px; border-radius: 5px; color: #fff; line-height: 1.4; min-width: 44px; text-align: center; } |
| 54 | −.method-get { background: var(--get); } .method-post { background: var(--post); color: var(--accent-ink); } .method-delete { background: var(--delete); } .method-put { background: var(--put); color: #1a1200; } .method-patch { background: var(--patch); } | |
| 55 | −[data-theme="light"] .method-post { color: #fff; } | |
| 78 | +.method-get { background: var(--get); color: #fff; } .method-post { background: var(--post); color: var(--accent-ink); } .method-delete { background: var(--delete); } .method-put { background: var(--put); color: #1a1200; } .method-patch { background: var(--patch); } .method-ws { background: var(--bg-3); color: var(--fg); } | |
| 79 | +[data-theme="dark"] .method-get { color: #041a2a; } | |
| 80 | +[data-theme="light"] .method-post, [data-theme="light"] .method-put { color: #fff; } | |
| 81 | + | |
| 82 | +/* Fields */ | |
| 83 | +.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; } | |
| 84 | +.field-inline { flex-direction: row; align-items: center; gap: 10px; } | |
| 85 | +.field-label { display: flex; align-items: baseline; gap: 6px; font-size: var(--fs-1); color: var(--fg-1); font-weight: 500; } | |
| 86 | +.field-req { color: var(--danger); } | |
| 87 | +.field-label-extra { margin-left: auto; font-size: var(--fs-0); color: var(--fg-3); font-weight: 400; } | |
| 88 | +.field-hint { font-size: var(--fs-0); color: var(--fg-2); line-height: 1.4; } | |
| 89 | +.field-err { font-size: var(--fs-0); color: var(--danger); } | |
| 90 | +.field .input, .field .select, .field .textarea { width: 100%; } | |
| 91 | + | |
| 92 | +/* Brand logos */ | |
| 93 | +.brand-logo-wrap { display: inline-grid; place-items: center; flex: none; } | |
| 94 | +.brand-logo { width: 100%; height: 100%; display: block; } | |
| 95 | + | |
| 96 | +/* Stacked table (mobile) */ | |
| 97 | +@media (max-width: 640px) { | |
| 98 | + .table-stack thead { display: none; } | |
| 99 | + .table-stack tr { display: block; padding: 8px 0; border-bottom: 1px solid var(--line); } | |
| 100 | + .table-stack tr:last-child { border-bottom: 0; } | |
| 101 | + .table-stack td { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px; border: 0; padding: 3px 12px; text-align: left; } | |
| 102 | + .table-stack td.num { text-align: left; } | |
| 103 | + .table-stack td::before { content: attr(data-label); color: var(--fg-2); font-size: var(--fs-0); text-transform: uppercase; letter-spacing: .04em; font-weight: 600; padding-top: 2px; } | |
| 104 | + .table-stack td:not([data-label])::before { content: none; } | |
| 105 | + .table-stack td:not([data-label]) { grid-template-columns: 1fr; } | |
| 106 | +} | |
| 56 | 107 | |
| 57 | 108 | /* Search dialog */ |
| 58 | −.search-backdrop { position: fixed; inset: 0; z-index: 80; background: rgba(0, 0, 0, .55); display: flex; justify-content: center; align-items: flex-start; padding: 12vh 16px 16px; } | |
| 59 | −.search-dialog { width: min(640px, 100%); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; } | |
| 60 | −.search-input-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); color: var(--fg-2); } | |
| 61 | −.search-input-row input { flex: 1; border: 0; background: transparent; padding: 6px 0; font-size: 16px; } | |
| 62 | −.search-input-row input:focus { outline: none; } | |
| 109 | +.search-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); background: rgba(0, 0, 0, .55); display: flex; justify-content: center; align-items: flex-start; padding: 12vh 16px 16px; } | |
| 110 | +.search-dialog { width: min(640px, 100%); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-lg); box-shadow: var(--shadow-2); overflow: hidden; } | |
| 111 | +.search-input-row { display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-bottom: 1px solid var(--line); color: var(--fg-2); } | |
| 112 | +.search-input-row input { flex: 1; border: 0; background: transparent; padding: 6px 0; font-size: 16px; min-height: 40px; } | |
| 113 | +.search-input-row input:focus-visible { outline: none; } | |
| 63 | 114 | .search-results { max-height: 52vh; overflow-y: auto; padding: 6px; } |
| 64 | 115 | .search-group { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-3); padding: 10px 10px 4px; font-weight: 650; } |
| 65 | −.search-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; color: var(--fg); cursor: pointer; } | |
| 116 | +.search-item { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 6px 10px; border-radius: var(--r-md); color: var(--fg); cursor: pointer; } | |
| 66 | 117 | .search-item[aria-selected="true"] { background: var(--bg-2); } |
| 67 | 118 | .search-item .method { flex: none; } |
| 68 | 119 | .search-item-text { min-width: 0; flex: 1; } |
| 69 | 120 | .search-item-title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 70 | −.search-item-sub { color: var(--fg-2); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); } | |
| 71 | −.search-item-tag { color: var(--fg-3); font-size: 12px; flex: none; } | |
| 121 | +.search-item-sub { color: var(--fg-2); font-size: var(--fs-0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); } | |
| 122 | +.search-item-tag { color: var(--fg-3); font-size: var(--fs-0); flex: none; } | |
| 72 | 123 | .search-empty { padding: 28px 12px; text-align: center; color: var(--fg-2); } |
| 73 | −.search-foot { display: flex; gap: 14px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--fg-3); font-size: 12px; } | |
| 124 | +.search-foot { display: flex; gap: 14px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--fg-3); font-size: var(--fs-0); } | |
| 74 | 125 | mark { background: var(--accent-soft); color: inherit; border-radius: 2px; } |
| 126 | +@media (max-width: 640px) { .search-backdrop { padding: 8px; align-items: flex-start; } .search-results { max-height: 70vh; } } | |
| 75 | 127 | |
| 76 | 128 | /* Markdown prose */ |
| 77 | 129 | .md > :first-child { margin-top: 0; } |
modified
hfmarketdata/web/src/components/index.js
+9 −2
@@ -1,10 +1,17 @@ | ||
| 1 | 1 | // Shared component barrel — see hfmarketdata/web/README.md for the API of each component. |
| 2 | −export { default as Code, CopyButton, useCopy } from './Code.jsx' | |
| 2 | +export { default as CodeBlock, default as Code } from './CodeBlock.jsx' | |
| 3 | +export { default as CopyButton, useCopy } from './CopyButton.jsx' | |
| 3 | 4 | export { default as CodeTabs } from './CodeTabs.jsx' |
| 5 | +export { default as Button, IconButton } from './Button.jsx' | |
| 4 | 6 | export { default as Tabs } from './Tabs.jsx' |
| 5 | 7 | export { default as Callout } from './Callout.jsx' |
| 8 | +export { default as Card } from './Card.jsx' | |
| 6 | 9 | export { default as Table } from './Table.jsx' |
| 7 | −export { default as Badge, MethodBadge, StatusBadge } from './Badge.jsx' | |
| 10 | +export { default as Field, Input, Select, Textarea } from './Field.jsx' | |
| 11 | +export { Skeleton, EmptyState, ErrorState, Stat, useCountdown, fmtDuration } from './States.jsx' | |
| 12 | +export { default as Badge, MethodBadge, StatusBadge, TierBadge } from './Badge.jsx' | |
| 13 | +export { default as BrandLogo, LOGOS } from './BrandLogos.jsx' | |
| 8 | 14 | export { default as LangSelector, LangProvider, useLang, LANGS } from './LangSelector.jsx' |
| 9 | 15 | export { default as Markdown, InlineMd } from './Markdown.jsx' |
| 16 | +export { default as useMediaQuery } from './useMediaQuery.js' | |
| 10 | 17 | export * from './Icons.jsx' |
added
hfmarketdata/web/src/components/useMediaQuery.js
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +// useMediaQuery('(max-width: 1199px)') → boolean, SSR/prerender-safe (false when window is undefined). | |
| 2 | +import { useEffect, useState } from 'react' | |
| 3 | + | |
| 4 | +export default function useMediaQuery(query) { | |
| 5 | + const get = () => (typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(query).matches : false) | |
| 6 | + const [matches, setMatches] = useState(get) | |
| 7 | + useEffect(() => { | |
| 8 | + if (typeof window === 'undefined' || !window.matchMedia) return undefined | |
| 9 | + const mq = window.matchMedia(query) | |
| 10 | + const on = e => setMatches(e.matches) | |
| 11 | + setMatches(mq.matches) | |
| 12 | + mq.addEventListener ? mq.addEventListener('change', on) : mq.addListener(on) | |
| 13 | + return () => { mq.removeEventListener ? mq.removeEventListener('change', on) : mq.removeListener(on) } | |
| 14 | + }, [query]) | |
| 15 | + return matches | |
| 16 | +} | |
modified
hfmarketdata/web/src/docs/mdx-components.jsx
+2 −2
@@ -4,7 +4,7 @@ import React from 'react' | ||
| 4 | 4 | import { Link } from 'react-router-dom' |
| 5 | 5 | import Badge, { MethodBadge } from '../components/Badge.jsx' |
| 6 | 6 | import Callout from '../components/Callout.jsx' |
| 7 | −import Code from '../components/Code.jsx' | |
| 7 | +import Code from '../components/CodeBlock.jsx' | |
| 8 | 8 | import CodeTabs from '../components/CodeTabs.jsx' |
| 9 | 9 | import Table from '../components/Table.jsx' |
| 10 | 10 | import Tabs from '../components/Tabs.jsx' |
@@ -26,7 +26,7 @@ const Heading = level => function H({ children, id, ...rest }) { | ||
| 26 | 26 | const slug = id || slugify(textOf(children)) |
| 27 | 27 | return ( |
| 28 | 28 | <Tag id={slug} className="anchor-heading" {...rest}> |
| 29 | − <a href={`#${slug}`} className="anchor" aria-label="Link to this section">#</a>{children} | |
| 29 | + <a href={`#${slug}`} className="anchor" aria-label="Link to this section" />{children} | |
| 30 | 30 | </Tag> |
| 31 | 31 | ) |
| 32 | 32 | } |
deleted
hfmarketdata/web/src/legacy-spec.js
+0 −366
@@ -1,366 +0,0 @@ | ||
| 1 | −// HF Market Data — API reference specification (rendered by the docs UI) | |
| 2 | −// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | − | |
| 4 | −export const BASE_URL = 'https://www.hfmarketdata.io' | |
| 5 | − | |
| 6 | −export const ASSET_TYPES = [ | |
| 7 | − { id: 'stock', name: 'Stocks', desc: 'US equities — every listed ticker, split & dividend adjusted variants.', adjustments: ['adj_split', 'adj_splitdiv', 'UNADJUSTED'], example: 'AAPL' }, | |
| 8 | − { id: 'etf', name: 'ETFs', desc: 'US exchange-traded funds, same adjustment variants as stocks.', adjustments: ['adj_split', 'adj_splitdiv', 'UNADJUSTED'], example: 'SPY' }, | |
| 9 | − { id: 'futures', name: 'Futures', desc: 'Continuous front-month series, roll-adjusted variants.', adjustments: ['contin_UNadj', 'contin_adj_ratio', 'contin_adj_absolute'], example: 'ES' }, | |
| 10 | − { id: 'futures_contracts', name: 'Futures contracts', desc: 'Individual expiry contracts, archive and current year.', adjustments: ['archive', 'update'], example: 'ESH24' }, | |
| 11 | − { id: 'crypto', name: 'Crypto', desc: 'Major cryptocurrency pairs.', adjustments: ['none'], example: 'BTCUSD' }, | |
| 12 | − { id: 'index', name: 'Indices', desc: 'Equity & volatility indices.', adjustments: ['none'], example: 'SPX' }, | |
| 13 | − { id: 'fx', name: 'FX', desc: 'Foreign-exchange pairs.', adjustments: ['none'], example: 'EURUSD' }, | |
| 14 | −] | |
| 15 | − | |
| 16 | −export const ASSET_IDS = ASSET_TYPES.map(a => a.id) | |
| 17 | −export const ADJUSTMENTS_BY_ASSET = Object.fromEntries( | |
| 18 | − ASSET_TYPES.map(a => [a.id, a.adjustments])) | |
| 19 | −export const EXAMPLE_TICKER = Object.fromEntries( | |
| 20 | − ASSET_TYPES.map(a => [a.id, a.example])) | |
| 21 | − | |
| 22 | −export const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day'] | |
| 23 | − | |
| 24 | −export const BAR_FIELDS = [ | |
| 25 | − { name: 'ticker', type: 'string', desc: 'Instrument symbol.' }, | |
| 26 | − { name: 'datetime', type: 'timestamp', desc: 'Bar timestamp — US Eastern Time for intraday, date for daily.' }, | |
| 27 | − { name: 'open', type: 'double', desc: 'Opening price of the bar.' }, | |
| 28 | − { name: 'high', type: 'double', desc: 'Highest traded price.' }, | |
| 29 | − { name: 'low', type: 'double', desc: 'Lowest traded price.' }, | |
| 30 | − { name: 'close', type: 'double', desc: 'Closing price of the bar.' }, | |
| 31 | − { name: 'volume', type: 'double', desc: 'Traded volume. Zero-volume bars are excluded from the dataset.' }, | |
| 32 | − { name: 'open_interest', type: 'double', desc: 'Open interest — daily futures bars only.' }, | |
| 33 | −] | |
| 34 | − | |
| 35 | −export const OPTION_FIELDS = [ | |
| 36 | − { name: 'ticker', type: 'string', desc: 'Underlying symbol.' }, | |
| 37 | − { name: 'trade_date', type: 'date', desc: 'Observation date (end-of-day snapshot, sampled 30s before the close).' }, | |
| 38 | − { name: 'strike', type: 'double', desc: 'Contract strike price.' }, | |
| 39 | − { name: 'expiry', type: 'date', desc: 'Contract expiration date.' }, | |
| 40 | − { name: 'call_put', type: 'string', desc: '"c" for calls, "p" for puts.' }, | |
| 41 | − { name: 'last_price', type: 'double', desc: 'Last traded price of the contract.' }, | |
| 42 | − { name: 'bid', type: 'double', desc: 'End-of-day best bid.' }, | |
| 43 | − { name: 'ask', type: 'double', desc: 'End-of-day best ask.' }, | |
| 44 | − { name: 'bid_iv', type: 'double', desc: 'Implied volatility computed from the bid.' }, | |
| 45 | − { name: 'ask_iv', type: 'double', desc: 'Implied volatility computed from the ask.' }, | |
| 46 | − { name: 'open_interest', type: 'double', desc: 'Open interest.' }, | |
| 47 | − { name: 'volume', type: 'double', desc: 'Contracts traded that day.' }, | |
| 48 | − { name: 'delta', type: 'double', desc: 'Δ — sensitivity of the option price to the underlying.' }, | |
| 49 | − { name: 'gamma', type: 'double', desc: 'Γ — rate of change of delta.' }, | |
| 50 | − { name: 'vega', type: 'double', desc: 'ν — sensitivity to implied volatility.' }, | |
| 51 | − { name: 'theta', type: 'double', desc: 'Θ — time decay.' }, | |
| 52 | − { name: 'rho', type: 'double', desc: 'ρ — sensitivity to interest rates.' }, | |
| 53 | −] | |
| 54 | − | |
| 55 | −export const ERRORS = [ | |
| 56 | − { code: 200, meaning: 'OK', desc: 'Request succeeded.' }, | |
| 57 | − { code: 400, meaning: 'Bad request', desc: 'Invalid parameter — unknown timeframe, adjustment not available for this asset type, malformed value. The detail field explains what to fix and lists valid values.' }, | |
| 58 | − { code: 404, meaning: 'Not found', desc: 'Unknown asset type, ticker or quarter. The detail field lists what is available.' }, | |
| 59 | − { code: 422, meaning: 'Validation error', desc: 'A query parameter failed type validation (FastAPI validation payload).' }, | |
| 60 | − { code: 503, meaning: 'Unavailable', desc: 'The requested dataset segment has not been ingested yet.' }, | |
| 61 | −] | |
| 62 | − | |
| 63 | −export const CAPABILITIES = [ | |
| 64 | − { title: 'Any window, to the minute', desc: 'start / end accept full timestamps — pull exactly 4 hours of 1-minute bars, one session, or 20 years of dailies.' }, | |
| 65 | − { title: 'Whole watchlists in one call', desc: 'Up to 50 tickers per request on /v1/bars/{asset} — per-ticker limits, rows grouped by symbol.' }, | |
| 66 | − { title: 'Point-in-time snapshots', desc: 'The state of every ticker you follow at 10:35:00 on any given day — one request.' }, | |
| 67 | − { title: 'Options with the full Greek set', desc: 'Chains, expirations and single-contract life stories — quotes, bid/ask IV, OI, delta gamma vega theta rho.' }, | |
| 68 | − { title: 'Bulk CSV extraction', desc: 'format=csv streams up to 2,000,000 rows per request — pandas.read_csv straight from the URL.' }, | |
| 69 | − { title: 'Every adjustment variant', desc: 'Split-only, split+dividend or raw unadjusted equities; ratio / absolute / unadjusted futures rolls.' }, | |
| 70 | −] | |
| 71 | − | |
| 72 | −// t: input type ('select' | 'text'), opts: fixed list or dynamic key, | |
| 73 | −// def: playground default, ph: placeholder | |
| 74 | −export const ENDPOINTS = [ | |
| 75 | − { | |
| 76 | − id: 'health', | |
| 77 | − method: 'GET', | |
| 78 | − path: '/health', | |
| 79 | − title: 'Health check', | |
| 80 | − tag: 'Meta', | |
| 81 | − desc: 'Service liveness probe. Returns service name, version and whether the data lake is mounted.', | |
| 82 | − params: [], | |
| 83 | − response: `{ | |
| 84 | − "status": "ok", | |
| 85 | − "service": "hfmarketdata-api", | |
| 86 | − "version": "1.0.0", | |
| 87 | − "data_root_present": true | |
| 88 | −}`, | |
| 89 | − }, | |
| 90 | − { | |
| 91 | − id: 'status', | |
| 92 | − method: 'GET', | |
| 93 | − path: '/v1/status', | |
| 94 | − title: 'Dataset inventory', | |
| 95 | − tag: 'Meta', | |
| 96 | − desc: 'Full inventory of the data lake: every asset type, timeframe and adjustment with the number of instruments available, plus the list of options quarters.', | |
| 97 | − params: [], | |
| 98 | − response: `{ | |
| 99 | − "datasets": { | |
| 100 | − "stock": { | |
| 101 | − "1min": { "adj_split": 7669, "adj_splitdiv": 7669, "UNADJUSTED": 7670 }, | |
| 102 | − "1day": { "adj_split": 7664, "adj_splitdiv": 7664, "UNADJUSTED": 7665 } | |
| 103 | − }, | |
| 104 | − "fx": { "1min": { "none": 79 } }, | |
| 105 | − "options": { | |
| 106 | − "quarters": ["2010_q1", "2010_q2", "…", "2026_q2"], | |
| 107 | − "files_latest_quarter": 5972 | |
| 108 | − } | |
| 109 | − } | |
| 110 | −}`, | |
| 111 | − }, | |
| 112 | − { | |
| 113 | − id: 'tickers', | |
| 114 | − method: 'GET', | |
| 115 | − path: '/v1/{asset}/tickers', | |
| 116 | − title: 'List tickers', | |
| 117 | − tag: 'Bars', | |
| 118 | − desc: 'Every instrument available for an asset type / timeframe / adjustment combination. Use it to discover coverage before requesting bars.', | |
| 119 | − params: [ | |
| 120 | − { name: 'asset', in: 'path', req: true, desc: 'Asset class', t: 'select', opts: 'ASSETS', def: 'stock' }, | |
| 121 | − { name: 'timeframe', in: 'query', req: false, desc: 'Bar resolution — default 1day', t: 'select', opts: TIMEFRAMES, def: '1day' }, | |
| 122 | − { name: 'adjustment', in: 'query', req: false, desc: 'Adjustment variant; sensible default per asset type', t: 'select', opts: 'ADJ_BY_ASSET', def: '' }, | |
| 123 | − { name: 'search', in: 'query', req: false, desc: 'Case-insensitive substring filter', t: 'text', def: 'AAP', ph: 'AAP' }, | |
| 124 | − { name: 'limit', in: 'query', req: false, desc: 'Max results — default 10 000', t: 'text', def: '', ph: '10000' }, | |
| 125 | − ], | |
| 126 | − response: `{ | |
| 127 | − "asset": "stock", | |
| 128 | − "timeframe": "1day", | |
| 129 | − "adjustment": "adj_splitdiv", | |
| 130 | − "count": 4, | |
| 131 | − "tickers": ["AAP", "AAPD", "AAPL", "AAPU"] | |
| 132 | −}`, | |
| 133 | − }, | |
| 134 | − { | |
| 135 | − id: 'bars', | |
| 136 | − method: 'GET', | |
| 137 | − path: '/v1/bars/{asset}/{ticker}', | |
| 138 | − title: 'OHLCV bars', | |
| 139 | − tag: 'Bars', | |
| 140 | − desc: 'Historical bars for one instrument, 1-minute to daily. start and end accept full timestamps, so any precise window works — one trading hour or twenty years. Daily futures bars also carry open interest.', | |
| 141 | − params: [ | |
| 142 | − { name: 'asset', in: 'path', req: true, desc: 'Asset class', t: 'select', opts: 'ASSETS', def: 'stock' }, | |
| 143 | − { name: 'ticker', in: 'path', req: true, desc: 'Instrument symbol — AAPL, SPY, ES, BTCUSD, EURUSD…', t: 'text', def: 'AAPL', ph: 'AAPL' }, | |
| 144 | − { name: 'timeframe', in: 'query', req: false, desc: 'Bar resolution — default 1day', t: 'select', opts: TIMEFRAMES, def: '1day' }, | |
| 145 | − { name: 'adjustment', in: 'query', req: false, desc: 'stock/etf: adj_split · adj_splitdiv · UNADJUSTED — futures: contin_UNadj · contin_adj_ratio · contin_adj_absolute', t: 'select', opts: 'ADJ_BY_ASSET', def: '' }, | |
| 146 | − { name: 'start', in: 'query', req: false, desc: 'ISO lower bound — 2024-01-01 or 2024-06-03 09:30:00', t: 'text', def: '2024-06-03', ph: '2024-06-03 09:30:00' }, | |
| 147 | − { name: 'end', in: 'query', req: false, desc: 'ISO upper bound', t: 'text', def: '', ph: '2024-06-07' }, | |
| 148 | − { name: 'order', in: 'query', req: false, desc: 'Sort direction', t: 'select', opts: ['asc', 'desc'], def: 'asc' }, | |
| 149 | − { name: 'limit', in: 'query', req: false, desc: 'Max rows — default 5 000 · JSON cap 50 000 · CSV cap 2 000 000', t: 'text', def: '5', ph: '5000' }, | |
| 150 | − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' }, | |
| 151 | − ], | |
| 152 | − response: `{ | |
| 153 | − "count": 2, | |
| 154 | − "data": [ | |
| 155 | − { "ticker": "AAPL", "datetime": "2024-06-03", "open": 192.9, | |
| 156 | − "high": 194.99, "low": 192.52, "close": 194.03, "volume": 50080500.0 }, | |
| 157 | − { "ticker": "AAPL", "datetime": "2024-06-04", "open": 194.64, | |
| 158 | − "high": 195.32, "low": 193.03, "close": 194.35, "volume": 47471400.0 } | |
| 159 | − ] | |
| 160 | −}`, | |
| 161 | − fields: 'bars', | |
| 162 | − }, | |
| 163 | − { | |
| 164 | − id: 'bars-multi', | |
| 165 | − method: 'GET', | |
| 166 | − path: '/v1/bars/{asset}', | |
| 167 | − title: 'Multi-ticker bars', | |
| 168 | − tag: 'Bars', | |
| 169 | − desc: 'Bars for several instruments in ONE call. Combine tickers with start/end to slice any precise window — e.g. 4 hours of 1-minute bars across a whole watchlist. The row limit applies per ticker, and rows come back grouped by ticker.', | |
| 170 | − params: [ | |
| 171 | − { name: 'asset', in: 'path', req: true, desc: 'Asset class', t: 'select', opts: 'ASSETS', def: 'stock' }, | |
| 172 | − { name: 'tickers', in: 'query', req: true, desc: 'Comma-separated list — max 50', t: 'text', def: 'AAPL,MSFT,NVDA', ph: 'AAPL,MSFT,NVDA' }, | |
| 173 | − { name: 'timeframe', in: 'query', req: false, desc: 'Bar resolution — default 1day', t: 'select', opts: TIMEFRAMES, def: '1min' }, | |
| 174 | − { name: 'adjustment', in: 'query', req: false, desc: 'Same variants as single-ticker bars', t: 'select', opts: 'ADJ_BY_ASSET', def: '' }, | |
| 175 | − { name: 'start', in: 'query', req: false, desc: 'ISO lower bound — precise to the minute', t: 'text', def: '2024-06-03 09:30:00', ph: '2024-06-03 09:30:00' }, | |
| 176 | − { name: 'end', in: 'query', req: false, desc: 'ISO upper bound', t: 'text', def: '2024-06-03 13:30:00', ph: '2024-06-03 13:30:00' }, | |
| 177 | − { name: 'order', in: 'query', req: false, desc: 'Sort direction', t: 'select', opts: ['asc', 'desc'], def: 'asc' }, | |
| 178 | − { name: 'limit', in: 'query', req: false, desc: 'Max rows PER TICKER — default 5 000', t: 'text', def: '3', ph: '5000' }, | |
| 179 | − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' }, | |
| 180 | − ], | |
| 181 | − response: `{ | |
| 182 | − "count": 720, | |
| 183 | − "data": [ | |
| 184 | − { "ticker": "AAPL", "datetime": "2024-06-03 09:30:00", "open": 192.9, "…": "…" }, | |
| 185 | − { "ticker": "AAPL", "datetime": "2024-06-03 09:31:00", "open": 192.7, "…": "…" }, | |
| 186 | − { "ticker": "MSFT", "datetime": "2024-06-03 09:30:00", "open": 415.2, "…": "…" } | |
| 187 | − ] | |
| 188 | −}`, | |
| 189 | − fields: 'bars', | |
| 190 | − }, | |
| 191 | − { | |
| 192 | − id: 'snapshot', | |
| 193 | − method: 'GET', | |
| 194 | − path: '/v1/snapshot/{asset}', | |
| 195 | − title: 'Point-in-time snapshot', | |
| 196 | − tag: 'Bars', | |
| 197 | − desc: 'The state of a whole watchlist at one precise moment: for each requested instrument, the last bar at or before the given timestamp. Perfect for reconstructing a cross-section of the market at 10:35:00 on any given day.', | |
| 198 | − params: [ | |
| 199 | − { name: 'asset', in: 'path', req: true, desc: 'Asset class', t: 'select', opts: 'ASSETS', def: 'stock' }, | |
| 200 | − { name: 'tickers', in: 'query', req: true, desc: 'Comma-separated list — max 50', t: 'text', def: 'AAPL,MSFT,NVDA', ph: 'AAPL,MSFT,NVDA' }, | |
| 201 | − { name: 'at', in: 'query', req: true, desc: 'The precise moment', t: 'text', def: '2024-06-03 10:35:00', ph: '2024-06-03 10:35:00' }, | |
| 202 | − { name: 'timeframe', in: 'query', req: false, desc: 'Bar resolution — default 1min', t: 'select', opts: TIMEFRAMES, def: '1min' }, | |
| 203 | − { name: 'adjustment', in: 'query', req: false, desc: 'Same variants as bars', t: 'select', opts: 'ADJ_BY_ASSET', def: '' }, | |
| 204 | − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' }, | |
| 205 | − ], | |
| 206 | − response: `{ | |
| 207 | − "count": 3, | |
| 208 | − "data": [ | |
| 209 | − { "ticker": "AAPL", "datetime": "2024-06-03 10:35:00", "close": 193.1, "…": "…" }, | |
| 210 | − { "ticker": "MSFT", "datetime": "2024-06-03 10:35:00", "close": 414.8, "…": "…" }, | |
| 211 | − { "ticker": "NVDA", "datetime": "2024-06-03 10:34:00", "close": 113.9, "…": "…" } | |
| 212 | − ] | |
| 213 | −}`, | |
| 214 | − fields: 'bars', | |
| 215 | − }, | |
| 216 | − { | |
| 217 | − id: 'opt-quarters', | |
| 218 | − method: 'GET', | |
| 219 | − path: '/v1/options/quarters', | |
| 220 | − title: 'Options quarters', | |
| 221 | − tag: 'Options', | |
| 222 | − desc: 'The quarterly archives available, from 2010_q1 through the current quarter. Each quarter holds one end-of-day chain file per underlying.', | |
| 223 | − params: [], | |
| 224 | − response: `{ | |
| 225 | − "quarters": ["2010_q1", "2010_q2", "2010_q3", "…", "2026_q1", "2026_q2"] | |
| 226 | −}`, | |
| 227 | − }, | |
| 228 | − { | |
| 229 | − id: 'opt-tickers', | |
| 230 | − method: 'GET', | |
| 231 | − path: '/v1/options/tickers', | |
| 232 | − title: 'Options underlyings', | |
| 233 | − tag: 'Options', | |
| 234 | − desc: 'Underlyings with options data in a given quarter — 5,800+ US equities and indices.', | |
| 235 | − params: [ | |
| 236 | − { name: 'quarter', in: 'query', req: false, desc: 'e.g. 2024_q4 — default: latest quarter', t: 'text', def: '', ph: '2024_q4' }, | |
| 237 | − { name: 'search', in: 'query', req: false, desc: 'Substring filter', t: 'text', def: 'TSL', ph: 'TSL' }, | |
| 238 | − { name: 'limit', in: 'query', req: false, desc: 'Max results — default 10 000', t: 'text', def: '', ph: '10000' }, | |
| 239 | − ], | |
| 240 | − response: `{ | |
| 241 | − "quarter": "2026_q2", | |
| 242 | − "count": 14, | |
| 243 | − "tickers": ["TSL", "TSLA", "TSLG", "TSLL", "…"] | |
| 244 | −}`, | |
| 245 | − }, | |
| 246 | − { | |
| 247 | − id: 'opt-chain', | |
| 248 | − method: 'GET', | |
| 249 | − path: '/v1/options/chain/{ticker}', | |
| 250 | − title: 'Option chain', | |
| 251 | − tag: 'Options', | |
| 252 | − desc: 'The complete end-of-day chain for an underlying on one trade date: last price, bid/ask quotes, bid/ask implied volatility, open interest, volume and the full set of Greeks. Snapshots are sampled 30 seconds before the close to avoid rebalancing-order noise.', | |
| 253 | − params: [ | |
| 254 | − { name: 'ticker', in: 'path', req: true, desc: 'Underlying symbol — AAPL, SPX…', t: 'text', def: 'AAPL', ph: 'AAPL' }, | |
| 255 | − { name: 'trade_date', in: 'query', req: false, desc: 'yyyy-mm-dd — default: latest available date', t: 'text', def: '2024-06-21', ph: '2024-06-21' }, | |
| 256 | − { name: 'expiry', in: 'query', req: false, desc: 'Restrict to a single expiry date', t: 'text', def: '', ph: '2024-12-20' }, | |
| 257 | − { name: 'call_put', in: 'query', req: false, desc: 'Side', t: 'select', opts: ['', 'c', 'p'], def: 'c' }, | |
| 258 | − { name: 'strike_min', in: 'query', req: false, desc: 'Minimum strike', t: 'text', def: '200', ph: '150' }, | |
| 259 | − { name: 'strike_max', in: 'query', req: false, desc: 'Maximum strike', t: 'text', def: '210', ph: '250' }, | |
| 260 | − { name: 'min_volume', in: 'query', req: false, desc: 'Minimum traded volume', t: 'text', def: '', ph: '10' }, | |
| 261 | − { name: 'limit', in: 'query', req: false, desc: 'Max rows — default 20 000', t: 'text', def: '5', ph: '20000' }, | |
| 262 | − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' }, | |
| 263 | − ], | |
| 264 | − response: `{ | |
| 265 | − "count": 1, | |
| 266 | − "data": [ | |
| 267 | − { "ticker": "AAPL", "trade_date": "2024-06-21", "strike": 200.0, | |
| 268 | − "expiry": "2024-06-28", "call_put": "c", "last_price": 8.0, | |
| 269 | − "bid": 7.85, "ask": 8.15, "bid_iv": 0.264, "ask_iv": 0.271, | |
| 270 | − "open_interest": 5231.0, "volume": 1930.0, "delta": 0.694, | |
| 271 | − "gamma": 0.041, "vega": 0.104, "theta": -0.213, "rho": 0.026 } | |
| 272 | − ] | |
| 273 | −}`, | |
| 274 | − fields: 'options', | |
| 275 | − }, | |
| 276 | − { | |
| 277 | − id: 'opt-expirations', | |
| 278 | − method: 'GET', | |
| 279 | − path: '/v1/options/expirations/{ticker}', | |
| 280 | − title: 'Expiration dates', | |
| 281 | − tag: 'Options', | |
| 282 | − desc: 'Available expiry dates for an underlying, optionally as of one trade date — the natural first call before requesting a filtered chain.', | |
| 283 | − params: [ | |
| 284 | − { name: 'ticker', in: 'path', req: true, desc: 'Underlying symbol', t: 'text', def: 'AAPL', ph: 'AAPL' }, | |
| 285 | − { name: 'trade_date', in: 'query', req: false, desc: 'yyyy-mm-dd', t: 'text', def: '2024-06-21', ph: '2024-06-21' }, | |
| 286 | − ], | |
| 287 | − response: `{ | |
| 288 | − "ticker": "AAPL", | |
| 289 | − "expirations": ["2024-06-28", "2024-07-05", "2024-07-19", "…"] | |
| 290 | −}`, | |
| 291 | − }, | |
| 292 | − { | |
| 293 | − id: 'opt-history', | |
| 294 | − method: 'GET', | |
| 295 | − path: '/v1/options/history/{ticker}', | |
| 296 | − title: 'Contract history', | |
| 297 | − tag: 'Options', | |
| 298 | − desc: 'The daily time series of one specific contract — strike + expiry + side — across its entire life: price, quotes, implied volatility, open interest and Greeks, day by day.', | |
| 299 | − params: [ | |
| 300 | − { name: 'ticker', in: 'path', req: true, desc: 'Underlying symbol', t: 'text', def: 'AAPL', ph: 'AAPL' }, | |
| 301 | − { name: 'strike', in: 'query', req: true, desc: 'Contract strike', t: 'text', def: '200', ph: '200' }, | |
| 302 | − { name: 'expiry', in: 'query', req: true, desc: 'Contract expiry — yyyy-mm-dd', t: 'text', def: '2024-12-20', ph: '2024-12-20' }, | |
| 303 | − { name: 'call_put', in: 'query', req: true, desc: 'Side', t: 'select', opts: ['c', 'p'], def: 'c' }, | |
| 304 | − { name: 'limit', in: 'query', req: false, desc: 'Max rows — default 5 000', t: 'text', def: '5', ph: '5000' }, | |
| 305 | − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' }, | |
| 306 | − ], | |
| 307 | − response: `{ | |
| 308 | − "count": 214, | |
| 309 | − "data": [ | |
| 310 | − { "ticker": "AAPL", "trade_date": "2024-01-02", "strike": 200.0, | |
| 311 | − "expiry": "2024-12-20", "call_put": "c", "last_price": 11.2, "…": "…" } | |
| 312 | − ] | |
| 313 | −}`, | |
| 314 | − fields: 'options', | |
| 315 | − }, | |
| 316 | −] | |
| 317 | − | |
| 318 | −// ---- URL building + code snippets (shared by docs & playground) ------------- | |
| 319 | − | |
| 320 | −export function buildUrl(ep, values) { | |
| 321 | − let path = ep.path | |
| 322 | − const query = [] | |
| 323 | − for (const p of ep.params) { | |
| 324 | − const v = (values[p.name] ?? '').toString().trim() | |
| 325 | − if (p.in === 'path') { | |
| 326 | − path = path.replace(`{${p.name}}`, encodeURIComponent(v || `{${p.name}}`)) | |
| 327 | − } else if (v !== '') { | |
| 328 | − query.push(`${p.name}=${encodeURIComponent(v).replace(/%2C/g, ',')}`) | |
| 329 | − } | |
| 330 | − } | |
| 331 | − return path + (query.length ? '?' + query.join('&') : '') | |
| 332 | −} | |
| 333 | − | |
| 334 | −export function defaultValues(ep) { | |
| 335 | − const v = {} | |
| 336 | − for (const p of ep.params) v[p.name] = p.def ?? '' | |
| 337 | − return v | |
| 338 | −} | |
| 339 | − | |
| 340 | −export function paramOptions(p, values) { | |
| 341 | − if (p.opts === 'ASSETS') return ASSET_IDS | |
| 342 | − if (p.opts === 'ADJ_BY_ASSET') { | |
| 343 | − const asset = values.asset || 'stock' | |
| 344 | − return ['', ...(ADJUSTMENTS_BY_ASSET[asset] || [])] | |
| 345 | − } | |
| 346 | − return p.opts || [] | |
| 347 | −} | |
| 348 | − | |
| 349 | −export function curlSnippet(url) { | |
| 350 | − return `curl "${BASE_URL}${url}"` | |
| 351 | −} | |
| 352 | − | |
| 353 | −export function pythonSnippet(url) { | |
| 354 | − return `import requests | |
| 355 | − | |
| 356 | −r = requests.get("${BASE_URL}${url}") | |
| 357 | −r.raise_for_status() | |
| 358 | −data = r.json() | |
| 359 | −print(f"{data.get('count', '')} rows")` | |
| 360 | −} | |
| 361 | − | |
| 362 | −export function jsSnippet(url) { | |
| 363 | − return `const res = await fetch("${BASE_URL}${url}"); | |
| 364 | −const data = await res.json(); | |
| 365 | −console.log(data);` | |
| 366 | −} | |
added
hfmarketdata/web/src/pages/NotFound.jsx
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +// 404 — document title, a search box (opens the ⌘K dialog with the query), and the three places people want. | |
| 2 | +import React, { useState } from 'react' | |
| 3 | +import { Link, useLocation } from 'react-router-dom' | |
| 4 | +import { CONTACT_EMAIL } from '../app/api.js' | |
| 5 | +import { BookIcon, PlayIcon, PulseIcon, SearchIcon } from '../components/Icons.jsx' | |
| 6 | +import useTitle from '../docs/useTitle.js' | |
| 7 | + | |
| 8 | +const SUGGESTIONS = [ | |
| 9 | + { to: '/docs', label: 'Documentation', sub: 'Guides and the generated API reference', icon: BookIcon }, | |
| 10 | + { to: '/playground', label: 'Playground', sub: 'Run any request in the browser', icon: PlayIcon }, | |
| 11 | + { to: '/status', label: 'Status', sub: 'Health and dataset inventory', icon: PulseIcon }, | |
| 12 | +] | |
| 13 | + | |
| 14 | +export default function NotFound() { | |
| 15 | + useTitle('Page not found', 'This page does not exist on HF Market Data.') | |
| 16 | + const { pathname } = useLocation() | |
| 17 | + const [q, setQ] = useState('') | |
| 18 | + // Layout listens for this event and opens the ⌘K dialog seeded with the query. | |
| 19 | + const submit = e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('hfmd:search', { detail: q })) } | |
| 20 | + return ( | |
| 21 | + <main className="page narrow notfound" data-testid="not-found"> | |
| 22 | + <p className="eyebrow">404</p> | |
| 23 | + <h1>There is nothing at <code className="notfound-path">{pathname}</code></h1> | |
| 24 | + <p className="lead">The page may have moved or the URL may be misspelled. Search the documentation, or jump to one of the usual places.</p> | |
| 25 | + <form className="notfound-search" onSubmit={submit} role="search"> | |
| 26 | + <label htmlFor="nf-q" className="sr-only">Search the documentation</label> | |
| 27 | + <input id="nf-q" type="search" value={q} onChange={e => setQ(e.target.value)} placeholder="Search endpoints, guides, error codes…" autoComplete="off" /> | |
| 28 | + <button type="submit" className="btn btn-primary"><SearchIcon /> Search</button> | |
| 29 | + </form> | |
| 30 | + <div className="notfound-links"> | |
| 31 | + {SUGGESTIONS.map(s => ( | |
| 32 | + <Link key={s.to} to={s.to} className="card card-sm notfound-card"><s.icon /><span><strong>{s.label}</strong><span className="muted small">{s.sub}</span></span></Link> | |
| 33 | + ))} | |
| 34 | + </div> | |
| 35 | + <p className="muted small">Think this is a broken link on our side? <a href={`mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(`Broken link: ${pathname}`)}`}>Tell us</a>.</p> | |
| 36 | + <style>{` | |
| 37 | + .notfound-path { font-size: .7em; vertical-align: middle; } | |
| 38 | + .notfound-search { display: flex; gap: 8px; margin: 20px 0; max-width: 560px; } | |
| 39 | + .notfound-search input { flex: 1; min-width: 0; } | |
| 40 | + .notfound-links { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 8px 0 22px; } | |
| 41 | + .notfound-card { display: flex; gap: 12px; align-items: flex-start; color: var(--fg); } | |
| 42 | + .notfound-card:hover { text-decoration: none; border-color: var(--line-3); } | |
| 43 | + .notfound-card svg { flex: none; color: var(--accent); margin-top: 2px; } | |
| 44 | + .notfound-card > span { display: grid; gap: 2px; } | |
| 45 | + @media (max-width: 640px) { .notfound-links { grid-template-columns: 1fr; } .notfound-search { flex-direction: column; } } | |
| 46 | + `}</style> | |
| 47 | + </main> | |
| 48 | + ) | |
| 49 | +} | |
modified
hfmarketdata/web/src/pages/docs/DocsHome.jsx
+1 −1
@@ -19,8 +19,8 @@ export default function DocsHome() { | ||
| 19 | 19 | const headings = SECTIONS.filter(s => s.id !== 'more').map(s => ({ depth: 2, text: s.title, id: s.id })) |
| 20 | 20 | const panel = ( |
| 21 | 21 | <> |
| 22 | − <div className="panel-block"><CodeTabs title="Your first request — no signup" snippets={snippets} maxHeight={420} /></div> | |
| 23 | 22 | <OnThisPage headings={headings} /> |
| 23 | + <div className="panel-block"><CodeTabs title="Your first request — no signup" snippets={snippets} maxHeight={420} /></div> | |
| 24 | 24 | </> |
| 25 | 25 | ) |
| 26 | 26 | return ( |
modified
hfmarketdata/web/src/pages/docs/DocsLayout.jsx
+28 −9
@@ -1,21 +1,30 @@ | ||
| 1 | −// Docs shell: left nav (guides + reference generated from the spec), DocPage (content + sticky code panel), | |
| 2 | −// OnThisPage outline, footer with Edit / Report links. DOM order = visual order on every breakpoint. | |
| 1 | +// Docs shell: left nav (guides + reference generated from the spec), DocPage (content + code panel), OnThisPage | |
| 2 | +// outline, footer with Edit / Report links. DOM order = visual order on every breakpoint: under 1200 px the panel | |
| 3 | +// is rendered INSIDE the article (after the intro, before the body) instead of in the right-hand aside. | |
| 3 | 4 | import React, { useEffect, useMemo, useState } from 'react' |
| 4 | 5 | import { Link, NavLink, useLocation } from 'react-router-dom' |
| 5 | 6 | import { CONTACT_EMAIL, PUBLIC_BASE } from '../../app/api.js' |
| 6 | 7 | import { REPO_URL } from '../../app/Layout.jsx' |
| 7 | 8 | import { MethodBadge } from '../../components/Badge.jsx' |
| 8 | 9 | import { ChevronIcon, EditIcon, GitIcon, MenuIcon, SearchIcon } from '../../components/Icons.jsx' |
| 10 | +import useMediaQuery from '../../components/useMediaQuery.js' | |
| 9 | 11 | import { EXTERNAL_LINKS, GUIDES, SECTIONS, STATIC_PAGES, guideHref } from '../../docs/guides.js' |
| 10 | 12 | import { groupByTag, listOperations, useSpec } from '../../docs/spec.js' |
| 11 | 13 | import './docs.css' |
| 12 | 14 | |
| 13 | 15 | const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) |
| 16 | +export const PANEL_BREAKPOINT = '(max-width: 1199px)' | |
| 14 | 17 | |
| 15 | 18 | export default function DocsLayout({ children }) { |
| 16 | 19 | const [open, setOpen] = useState(false) |
| 17 | 20 | const location = useLocation() |
| 18 | 21 | useEffect(() => { setOpen(false) }, [location.pathname]) |
| 22 | + useEffect(() => { | |
| 23 | + if (!open) return undefined | |
| 24 | + const onKey = e => { if (e.key === 'Escape') setOpen(false) } | |
| 25 | + window.addEventListener('keydown', onKey) | |
| 26 | + return () => window.removeEventListener('keydown', onKey) | |
| 27 | + }, [open]) | |
| 19 | 28 | return ( |
| 20 | 29 | <div className="docs"> |
| 21 | 30 | <div className="docs-nav-toggle"> |
@@ -36,7 +45,7 @@ function currentLabel(path) { | ||
| 36 | 45 | return 'Overview' |
| 37 | 46 | } |
| 38 | 47 | |
| 39 | −function openSearch() { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true, bubbles: true })) } | |
| 48 | +function openSearch() { window.dispatchEvent(new CustomEvent('hfmd:search', { detail: '' })) } | |
| 40 | 49 | |
| 41 | 50 | export function DocsNav({ open }) { |
| 42 | 51 | const { spec } = useSpec() |
@@ -81,9 +90,15 @@ export function DocsNav({ open }) { | ||
| 81 | 90 | ) |
| 82 | 91 | } |
| 83 | 92 | |
| 84 | −/** Centre column + sticky right panel. `panel` null → single wide column. */ | |
| 85 | −export function DocPage({ children, panel, crumbs, footer = true, testId }) { | |
| 93 | +/** | |
| 94 | + * Centre column + right panel. `intro` = header block (h1, description, actions) · `children` = body · | |
| 95 | + * `after` = prev/next · `panel` = code + outline. Wide: panel in a sticky aside. Narrow: panel rendered inside the | |
| 96 | + * article right after `intro` (or before `after` when there is no intro), so request/response come before Parameters. | |
| 97 | + */ | |
| 98 | +export function DocPage({ children, intro, after, panel, crumbs, footer = true, testId }) { | |
| 86 | 99 | const location = useLocation() |
| 100 | + const narrow = useMediaQuery(PANEL_BREAKPOINT) | |
| 101 | + const inline = narrow && panel ? <div className="docs-panel-inline" data-testid="docs-panel-inline">{panel}</div> : null | |
| 87 | 102 | return ( |
| 88 | 103 | <div className={`docpage ${panel ? '' : 'no-panel'}`} data-testid={testId}> |
| 89 | 104 | <article className="docs-content"> |
@@ -93,10 +108,14 @@ export function DocPage({ children, panel, crumbs, footer = true, testId }) { | ||
| 93 | 108 | {crumbs.map((c, i) => <React.Fragment key={i}><ChevronIcon />{c.href ? <Link to={c.href}>{c.label}</Link> : <span>{c.label}</span>}</React.Fragment>)} |
| 94 | 109 | </div> |
| 95 | 110 | )} |
| 111 | + {intro} | |
| 112 | + {intro ? inline : null} | |
| 96 | 113 | {children} |
| 114 | + {!intro ? inline : null} | |
| 115 | + {after} | |
| 97 | 116 | {footer && <DocsFooter path={location.pathname} />} |
| 98 | 117 | </article> |
| 99 | − {panel && <aside className="docs-panel" aria-label="Code and outline"><div className="docs-panel-inner">{panel}</div></aside>} | |
| 118 | + {panel && !narrow && <aside className="docs-panel" aria-label="Code and outline"><div className="docs-panel-inner">{panel}</div></aside>} | |
| 100 | 119 | </div> |
| 101 | 120 | ) |
| 102 | 121 | } |
@@ -116,9 +135,9 @@ export function DocsFooter({ path }) { | ||
| 116 | 135 | export function OnThisPage({ headings }) { |
| 117 | 136 | const [active, setActive] = useState(null) |
| 118 | 137 | useEffect(() => { |
| 119 | − if (!headings?.length || typeof IntersectionObserver === 'undefined') return | |
| 138 | + if (!headings?.length || typeof IntersectionObserver === 'undefined') return undefined | |
| 120 | 139 | const els = headings.map(h => document.getElementById(h.id)).filter(Boolean) |
| 121 | − if (!els.length) return | |
| 140 | + if (!els.length) return undefined | |
| 122 | 141 | const io = new IntersectionObserver(entries => { |
| 123 | 142 | const visible = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) |
| 124 | 143 | if (visible[0]) setActive(visible[0].target.id) |
@@ -128,7 +147,7 @@ export function OnThisPage({ headings }) { | ||
| 128 | 147 | }, [headings]) |
| 129 | 148 | if (!headings?.length) return null |
| 130 | 149 | return ( |
| 131 | − <div className="panel-block"> | |
| 150 | + <div className="panel-block panel-toc"> | |
| 132 | 151 | <h4>On this page</h4> |
| 133 | 152 | <nav className="toc" aria-label="On this page"> |
| 134 | 153 | {headings.map(h => <a key={h.id} href={`#${h.id}`} className={`depth-${h.depth} ${active === h.id ? 'active' : ''}`}>{h.text}</a>)} |
modified
hfmarketdata/web/src/pages/docs/ErrorsPage.jsx
+2 −2
@@ -3,7 +3,7 @@ | ||
| 3 | 3 | import React, { useEffect, useMemo } from 'react' |
| 4 | 4 | import { Link, useLocation } from 'react-router-dom' |
| 5 | 5 | import { StatusBadge } from '../../components/Badge.jsx' |
| 6 | −import Code from '../../components/Code.jsx' | |
| 6 | +import Code from '../../components/CodeBlock.jsx' | |
| 7 | 7 | import Callout from '../../components/Callout.jsx' |
| 8 | 8 | import { errorCodes, useSpec } from '../../docs/spec.js' |
| 9 | 9 | import useTitle from '../../docs/useTitle.js' |
@@ -72,7 +72,7 @@ export default function ErrorsPage() { | ||
| 72 | 72 | const info = ERROR_INFO[c] || { status: 400, meaning: 'See the endpoint description.', fix: '' } |
| 73 | 73 | return ( |
| 74 | 74 | <div className="error-code-block" key={c} id={c.toLowerCase()}> |
| 75 | − <h3><StatusBadge code={info.status} /> {c} <a href={`#${c.toLowerCase()}`} className="anchor" aria-label="Link to this code">#</a></h3> | |
| 75 | + <h3 className="anchor-heading"><StatusBadge code={info.status} /> {c} <a href={`#${c.toLowerCase()}`} className="anchor" aria-label={`Link to ${c}`} /></h3> | |
| 76 | 76 | <p><Md text={info.meaning} /></p> |
| 77 | 77 | {info.fix && <p className="muted"><strong>Fix:</strong> <Md text={info.fix} /></p>} |
| 78 | 78 | </div> |
modified
hfmarketdata/web/src/pages/docs/GuidePage.jsx
+2 −2
@@ -47,7 +47,8 @@ export default function GuidePage({ slug }) { | ||
| 47 | 47 | ) |
| 48 | 48 | const Content = mod?.default |
| 49 | 49 | return ( |
| 50 | − <DocPage crumbs={[{ label: sectionTitle(guide.section) }, { label: guide.title }]} panel={panel} testId="guide-page"> | |
| 50 | + <DocPage crumbs={[{ label: sectionTitle(guide.section) }, { label: guide.title }]} panel={panel} testId="guide-page" | |
| 51 | + after={<PrevNext prev={prev && { href: guideHref(prev), title: prev.title }} next={next && { href: guideHref(next), title: next.title }} />}> | |
| 51 | 52 | {error && <p role="alert" className="muted">Could not load this guide ({String(error.message || error)}).</p>} |
| 52 | 53 | {!mod && !error && <p className="muted" aria-busy="true">Loading…</p>} |
| 53 | 54 | {Content && ( |
@@ -55,7 +56,6 @@ export default function GuidePage({ slug }) { | ||
| 55 | 56 | <MDXProvider components={mdxComponents}><Content /></MDXProvider> |
| 56 | 57 | </div> |
| 57 | 58 | )} |
| 58 | − <PrevNext prev={prev && { href: guideHref(prev), title: prev.title }} next={next && { href: guideHref(next), title: next.title }} /> | |
| 59 | 59 | </DocPage> |
| 60 | 60 | ) |
| 61 | 61 | } |
modified
hfmarketdata/web/src/pages/docs/OperationPage.jsx
+84 −56
@@ -1,15 +1,17 @@ | ||
| 1 | 1 | // One page per OpenAPI operation: method + path, markdown description, parameters, request samples |
| 2 | −// (curl / Python / JS / R), response example (spec → captured file → live fetch for public GETs), errors, Try it. | |
| 3 | −import React, { useEffect, useMemo, useState } from 'react' | |
| 2 | +// (curl / Python / JS / R), response example (spec → captured file; "Run live" on demand only — the docs never | |
| 3 | +// spend the visitor's keyless quota by themselves), errors, Try it. | |
| 4 | +import React, { useCallback, useEffect, useMemo, useState } from 'react' | |
| 4 | 5 | import { Link, useParams } from 'react-router-dom' |
| 5 | −import { BASE_URL, PUBLIC_BASE } from '../../app/api.js' | |
| 6 | +import { BASE_URL, PUBLIC_BASE, rateHeaders } from '../../app/api.js' | |
| 6 | 7 | import Badge, { MethodBadge, StatusBadge } from '../../components/Badge.jsx' |
| 7 | 8 | import Callout from '../../components/Callout.jsx' |
| 8 | −import Code from '../../components/Code.jsx' | |
| 9 | +import Code from '../../components/CodeBlock.jsx' | |
| 9 | 10 | import CodeTabs from '../../components/CodeTabs.jsx' |
| 10 | 11 | import { PlayIcon } from '../../components/Icons.jsx' |
| 11 | 12 | import Markdown, { InlineMd } from '../../components/Markdown.jsx' |
| 12 | −import { buildRequest, exampleParams, hasExampleFile, loadExampleFile, playgroundLink } from '../../docs/enrich.js' | |
| 13 | +import { fmtDuration, useCountdown } from '../../components/States.jsx' | |
| 14 | +import { buildRequest, exampleParams, loadExampleFile, playgroundLink } from '../../docs/enrich.js' | |
| 13 | 15 | import { snippetsFor } from '../../docs/snippets.js' |
| 14 | 16 | import { errorResponses, listOperations, resolveRef, responseExample, useSpec } from '../../docs/spec.js' |
| 15 | 17 | import useTitle from '../../docs/useTitle.js' |
@@ -40,29 +42,42 @@ function Operation({ op, spec, source, prev, next }) { | ||
| 40 | 42 | const specExample = useMemo(() => responseExample(spec, op), [spec, op]) |
| 41 | 43 | const errors = useMemo(() => errorResponses(spec, op), [spec, op]) |
| 42 | 44 | const [example, setExample] = useState(specExample ? { ...specExample, origin: 'spec' } : null) |
| 45 | + const [live, setLive] = useState({ state: 'idle' }) // idle | running | done | error | |
| 46 | + const retryLeft = useCountdown(live.retryUntil || null) | |
| 47 | + const canRunLive = op.method === 'GET' && PUBLIC_TAGS.has(op.tag) && !isStream && !needsAuth | |
| 43 | 48 | |
| 44 | 49 | useEffect(() => { |
| 45 | − if (specExample) return | |
| 50 | + if (specExample) return undefined | |
| 46 | 51 | let alive = true |
| 47 | − ;(async () => { | |
| 48 | − const file = await loadExampleFile(op.id) | |
| 49 | − if (file && alive) { setExample({ code: file.status || 200, value: file.body ?? file, origin: 'captured', capturedAt: file.captured_at, url: file.url }); return } | |
| 50 | − if (op.method !== 'GET' || !PUBLIC_TAGS.has(op.tag) || isStream) return | |
| 51 | − if (liveCache.has(op.id)) { alive && setExample(liveCache.get(op.id)); return } | |
| 52 | − try { | |
| 53 | − const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 8000) | |
| 54 | − const res = await fetch(BASE_URL + req.rel, { headers: { Accept: 'application/json' }, signal: ctl.signal }) | |
| 55 | − clearTimeout(t) | |
| 56 | − const ct = res.headers.get('content-type') || '' | |
| 57 | − if (!ct.includes('json')) return | |
| 58 | − const body = await res.json() | |
| 59 | − const ex = { code: res.status, value: trimExample(body), origin: 'live', url: PUBLIC_BASE + req.rel } | |
| 60 | − liveCache.set(op.id, ex) | |
| 61 | − alive && setExample(ex) | |
| 62 | − } catch { /* offline — no example */ } | |
| 63 | − })() | |
| 52 | + loadExampleFile(op.id).then(file => { if (file && alive) setExample({ code: file.status || 200, value: file.body ?? file, origin: 'captured', capturedAt: file.captured_at, url: file.url }) }) | |
| 64 | 53 | return () => { alive = false } |
| 65 | − }, [op.id, op.method, op.tag, req.rel, specExample, isStream]) | |
| 54 | + }, [op.id, specExample]) | |
| 55 | + | |
| 56 | + const runLive = useCallback(async () => { | |
| 57 | + if (liveCache.has(op.id)) { setExample(liveCache.get(op.id)); setLive({ state: 'done' }); return } | |
| 58 | + setLive({ state: 'running' }) | |
| 59 | + try { | |
| 60 | + const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 10000) | |
| 61 | + const t0 = performance.now() | |
| 62 | + const res = await fetch(BASE_URL + req.rel, { headers: { Accept: 'application/json' }, signal: ctl.signal }) | |
| 63 | + clearTimeout(t) | |
| 64 | + const ms = Math.round(performance.now() - t0) | |
| 65 | + const rate = rateHeaders(res) | |
| 66 | + const ct = res.headers.get('content-type') || '' | |
| 67 | + const body = ct.includes('json') ? await res.json() : { text: (await res.text()).slice(0, 2000) } | |
| 68 | + if (res.status === 429) { | |
| 69 | + const wait = rate.retryAfter != null ? rate.retryAfter : rate.reset ? rate.reset - Math.floor(Date.now() / 1000) : 60 | |
| 70 | + setLive({ state: 'error', status: 429, retryUntil: Math.floor(Date.now() / 1000) + Math.max(1, wait), message: body?.error?.message }) | |
| 71 | + return | |
| 72 | + } | |
| 73 | + const ex = { code: res.status, value: trimExample(body), origin: 'live', url: PUBLIC_BASE + req.rel, ms, rate } | |
| 74 | + if (res.ok) liveCache.set(op.id, ex) | |
| 75 | + setExample(ex) | |
| 76 | + setLive({ state: 'done', status: res.status }) | |
| 77 | + } catch (e) { | |
| 78 | + setLive({ state: 'error', status: 0, message: e.name === 'AbortError' ? 'The request timed out.' : 'The API could not be reached.' }) | |
| 79 | + } | |
| 80 | + }, [op.id, req.rel]) | |
| 66 | 81 | |
| 67 | 82 | const successCodes = Object.keys(op.responses).filter(c => c.startsWith('2')) |
| 68 | 83 | const success = successCodes.map(c => resolveRef(spec, op.responses[c])) |
@@ -75,52 +90,67 @@ function Operation({ op, spec, source, prev, next }) { | ||
| 75 | 90 | { depth: 2, text: 'Response', id: 'response' }, { depth: 2, text: 'Errors', id: 'errors' }, |
| 76 | 91 | ] |
| 77 | 92 | |
| 93 | + const exampleTitle = example ? `Response · ${example.code}${example.origin === 'live' ? ` · live${example.ms ? ` · ${example.ms} ms` : ''}` : example.origin === 'captured' ? ' · captured' : ''}` : 'Response' | |
| 94 | + const runBtn = canRunLive && ( | |
| 95 | + <button type="button" className="copy-btn run-live" onClick={runLive} disabled={live.state === 'running' || (live.status === 429 && retryLeft > 0)} data-testid="run-live" title="Fetch this example from the live API (counts against your quota)"> | |
| 96 | + <PlayIcon /> {live.state === 'running' ? 'Running…' : example?.origin === 'live' ? 'Run again' : 'Run live'} | |
| 97 | + </button> | |
| 98 | + ) | |
| 99 | + const exampleFooter = live.state === 'error' | |
| 100 | + ? (live.status === 429 | |
| 101 | + ? <p className="source-note">Rate limited — not down. Your keyless quota is used up; retry in <span className="mono">{fmtDuration(retryLeft)}</span> or <Link to="/signup">create a free account</Link> (120 requests / minute).</p> | |
| 102 | + : <p className="source-note">{live.message}</p>) | |
| 103 | + : example?.origin === 'live' ? <p className="source-note">Fetched live from <code>{example.url}</code>{example.rate?.remainingRequests != null ? ` · ${example.rate.remainingRequests} keyless requests left this hour` : ''}.</p> | |
| 104 | + : example?.origin === 'captured' ? <p className="source-note">Captured from production{example.capturedAt ? ` on ${example.capturedAt.slice(0, 10)}` : ''}{canRunLive ? ' — “Run live” fetches a fresh copy (one request of your quota).' : '.'}</p> | |
| 105 | + : null | |
| 106 | + | |
| 78 | 107 | const panel = ( |
| 79 | 108 | <> |
| 109 | + <OnThisPage headings={headings} /> | |
| 80 | 110 | <div className="panel-block"> |
| 81 | 111 | <CodeTabs title={`${op.method} request`} snippets={snippets} maxHeight={420} /> |
| 82 | 112 | </div> |
| 83 | 113 | <div className="panel-block"> |
| 84 | − <Code title={example ? `Response · ${example.code}${example.origin === 'live' ? ' · live' : example.origin === 'captured' ? ' · captured' : ''}` : 'Response'} language="json" maxHeight={520} | |
| 85 | − code={example ? JSON.stringify(example.value, null, 2) : `// No example in the spec yet${op.method === 'GET' && PUBLIC_TAGS.has(op.tag) ? ' — fetching a live one…' : ''}`} /> | |
| 86 | − {example?.origin === 'live' && <p className="source-note">Fetched live from <code>{example.url}</code>.</p>} | |
| 87 | − {example?.origin === 'captured' && <p className="source-note">Captured from production{example.capturedAt ? ` on ${example.capturedAt.slice(0, 10)}` : ''} by <code>scripts/fetch-examples.mjs</code>.</p>} | |
| 114 | + <Code title={exampleTitle} language="json" maxHeight={520} actions={runBtn} footer={exampleFooter} data-testid="response-example" | |
| 115 | + code={example ? JSON.stringify(example.value, null, 2) : `// No example in the spec yet${canRunLive ? ' — press “Run live” to fetch one.' : '.'}`} /> | |
| 88 | 116 | </div> |
| 89 | − <OnThisPage headings={headings} /> | |
| 90 | 117 | </> |
| 91 | 118 | ) |
| 92 | 119 | |
| 93 | − return ( | |
| 94 | − <DocPage crumbs={[{ label: 'Reference', href: '/docs/reference' }, { label: op.tag, href: `/docs/reference/tag/${op.tag}` }]} panel={panel} testId="operation-page"> | |
| 95 | − <header> | |
| 96 | − <h1>{op.summary}</h1> | |
| 97 | − <div className="op-head"> | |
| 98 | − <MethodBadge method={op.method} /> | |
| 99 | − <span className="op-path">{PUBLIC_BASE}{highlightPath(op.path)}</span> | |
| 100 | − {op.deprecated && <Badge tone="warn">Deprecated</Badge>} | |
| 101 | − {needsAuth ? <Badge tone="info">Requires API key</Badge> : <Badge tone="success">Public · keyless OK</Badge>} | |
| 102 | − </div> | |
| 103 | − {op.description ? <Markdown className="prose" text={op.description} headingOffset={2} /> : <p className="muted">No description in the specification yet.</p>} | |
| 104 | − <div className="op-actions"> | |
| 105 | − {!isStream && <Link to={playgroundLink(op, values)} className="btn btn-primary" data-testid="try-it"><PlayIcon /> Try it in the playground</Link>} | |
| 106 | − <a className="btn" href={`${PUBLIC_BASE}/openapi.json`}>OpenAPI JSON</a> | |
| 107 | − </div> | |
| 108 | − </header> | |
| 120 | + const intro = ( | |
| 121 | + <header> | |
| 122 | + <h1>{op.summary}</h1> | |
| 123 | + <div className="op-head"> | |
| 124 | + <MethodBadge method={op.method} /> | |
| 125 | + <span className="op-path">{PUBLIC_BASE}{highlightPath(op.path)}</span> | |
| 126 | + {op.deprecated && <Badge tone="warn">Deprecated</Badge>} | |
| 127 | + {needsAuth ? <Badge tone="info">Requires API key</Badge> : <Badge tone="success">Public · keyless OK</Badge>} | |
| 128 | + </div> | |
| 129 | + {op.description ? <Markdown className="prose" text={op.description} headingOffset={2} /> : <p className="muted">No description in the specification yet.</p>} | |
| 130 | + <div className="op-actions"> | |
| 131 | + {!isStream && <Link to={playgroundLink(op, values)} className="btn btn-primary" data-testid="try-it"><PlayIcon /> Try it in the playground</Link>} | |
| 132 | + <a className="btn" href={`${PUBLIC_BASE}/openapi.json`}>OpenAPI JSON</a> | |
| 133 | + </div> | |
| 134 | + </header> | |
| 135 | + ) | |
| 109 | 136 | |
| 137 | + return ( | |
| 138 | + <DocPage crumbs={[{ label: 'Reference', href: '/docs/reference' }, { label: op.tag, href: `/docs/reference/tag/${op.tag}` }]} intro={intro} panel={panel} testId="operation-page" | |
| 139 | + after={<PrevNext prev={prev && { href: `/docs/reference/${prev.id}`, title: prev.summary }} next={next && { href: `/docs/reference/${next.id}`, title: next.summary }} />}> | |
| 110 | 140 | <section> |
| 111 | 141 | <h2 id="parameters">Parameters</h2> |
| 112 | 142 | {params.length === 0 && <p className="muted">This endpoint takes no parameters.</p>} |
| 113 | 143 | {params.length > 0 && ( |
| 114 | − <div className="table-wrap"> | |
| 144 | + <div className="table-wrap table-stack params-table"> | |
| 115 | 145 | <table> |
| 116 | 146 | <thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr></thead> |
| 117 | 147 | <tbody> |
| 118 | 148 | {params.map(p => ( |
| 119 | 149 | <tr key={`${p.in}-${p.name}`} id={`param-${p.name}`}> |
| 120 | − <td><span className="param-name">{p.name}</span><span className="param-in">{p.in}</span>{p.required && <span className="param-req">required</span>}</td> | |
| 121 | − <td><span className="param-type">{p.type}</span>{p.constraints.length > 0 && <div className="param-constraints">{p.constraints.join(' · ')}</div>}</td> | |
| 122 | − <td>{p.default !== undefined ? <span className="param-default">{String(p.default)}</span> : <span className="muted">—</span>}</td> | |
| 123 | − <td> | |
| 150 | + <td data-label="Name"><span className="param-name">{p.name}</span><span className="param-in">{p.in}</span>{p.required && <span className="param-req">required</span>}</td> | |
| 151 | + <td data-label="Type"><span className="param-type">{p.type}</span>{p.constraints.length > 0 && <div className="param-constraints">{p.constraints.join(' · ')}</div>}</td> | |
| 152 | + <td data-label="Default">{p.default !== undefined ? <span className="param-default">{String(p.default)}</span> : <span className="muted">—</span>}</td> | |
| 153 | + <td data-label="Description"> | |
| 124 | 154 | <div className="param-desc"><InlineMd text={p.description} /></div> |
| 125 | 155 | {p.enum && <div className="param-enum">{p.enum.map(v => <code key={String(v)}>{String(v)}</code>)}</div>} |
| 126 | 156 | {values[p.name] !== undefined && <div className="param-constraints">Example: <code>{values[p.name]}</code></div>} |
@@ -177,8 +207,6 @@ function Operation({ op, spec, source, prev, next }) { | ||
| 177 | 207 | ))} |
| 178 | 208 | </div> |
| 179 | 209 | </section> |
| 180 | − | |
| 181 | − <PrevNext prev={prev && { href: `/docs/reference/${prev.id}`, title: prev.summary }} next={next && { href: `/docs/reference/${next.id}`, title: next.summary }} /> | |
| 182 | 210 | </DocPage> |
| 183 | 211 | ) |
| 184 | 212 | } |
@@ -205,7 +233,7 @@ export function SchemaTable({ spec, schema, depth = 0 }) { | ||
| 205 | 233 | } |
| 206 | 234 | const required = new Set(s.required || resolveRef(spec, s.items)?.required || []) |
| 207 | 235 | return ( |
| 208 | − <div className="table-wrap" style={{ marginBottom: 12 }}> | |
| 236 | + <div className="table-wrap table-stack" style={{ marginBottom: 12 }}> | |
| 209 | 237 | <table> |
| 210 | 238 | <thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead> |
| 211 | 239 | <tbody> |
@@ -215,9 +243,9 @@ export function SchemaTable({ spec, schema, depth = 0 }) { | ||
| 215 | 243 | const inner = (p.type === 'object' || p.type === 'array') && depth < 1 && (p.properties || resolveRef(spec, p.items)?.properties) |
| 216 | 244 | return ( |
| 217 | 245 | <tr key={name}> |
| 218 | − <td><span className="param-name">{name}</span>{required.has(name) && <span className="param-req">required</span>}</td> | |
| 219 | − <td><span className="param-type">{type}{p.type === 'array' && p.items ? `<${resolveRef(spec, p.items)?.type || 'object'}>` : ''}{p.format ? ` (${p.format})` : ''}</span></td> | |
| 220 | − <td> | |
| 246 | + <td data-label="Field"><span className="param-name">{name}</span>{required.has(name) && <span className="param-req">required</span>}</td> | |
| 247 | + <td data-label="Type"><span className="param-type">{type}{p.type === 'array' && p.items ? `<${resolveRef(spec, p.items)?.type || 'object'}>` : ''}{p.format ? ` (${p.format})` : ''}</span></td> | |
| 248 | + <td data-label="Description"> | |
| 221 | 249 | <div className="param-desc"><InlineMd text={p.description || ''} /></div> |
| 222 | 250 | {p.enum && <div className="param-enum">{p.enum.map(v => <code key={String(v)}>{String(v)}</code>)}</div>} |
| 223 | 251 | {p.example !== undefined && <div className="param-constraints">Example: <code>{JSON.stringify(p.example)}</code></div>} |
modified
hfmarketdata/web/src/pages/docs/docs.css
+68 −52
@@ -1,137 +1,153 @@ | ||
| 1 | −/* Docs: 3 columns (nav · content · code panel). DOM order = visual order on every breakpoint. */ | |
| 1 | +/* Docs: 3 columns (nav 250 · content ≤ 720 · panel 420) from 1200 px. DOM order = visual order on every breakpoint: | |
| 2 | + under 1200 the panel is rendered inside the article (see DocsLayout.jsx › DocPage). */ | |
| 2 | 3 | .docs { display: grid; grid-template-columns: 250px minmax(0, 1fr); flex: 1; width: 100%; max-width: 1480px; margin: 0 auto; } |
| 3 | 4 | .docs-nav { position: sticky; top: var(--topbar-h); align-self: start; height: calc(100dvh - var(--topbar-h)); overflow-y: auto; padding: 22px 12px 40px 20px; border-right: 1px solid var(--line); scrollbar-width: thin; } |
| 4 | 5 | .docs-nav-section { margin-bottom: 18px; } |
| 5 | 6 | .docs-nav-title { font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--fg-3); padding: 0 8px; margin-bottom: 6px; } |
| 6 | −.docs-nav a { display: block; padding: 5px 8px; border-radius: 6px; color: var(--fg-1); font-size: 13.5px; line-height: 1.35; } | |
| 7 | +.docs-nav a { display: flex; align-items: center; min-height: var(--tap); padding: 4px 8px; border-radius: var(--r-sm); color: var(--fg-1); font-size: var(--fs-1); line-height: 1.35; } | |
| 7 | 8 | .docs-nav a:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; } |
| 8 | 9 | .docs-nav a.active { color: var(--fg); background: var(--accent-soft); font-weight: 550; } |
| 9 | −.docs-nav .nav-op { display: flex; align-items: center; gap: 7px; padding-left: 8px; font-size: 13px; } | |
| 10 | +.docs-nav .nav-op { gap: 7px; padding-left: 8px; font-size: var(--fs-1); } | |
| 10 | 11 | .docs-nav .nav-op .method { min-width: 0; font-size: 9.5px; padding: 1px 4px; } |
| 11 | 12 | .docs-nav details { margin-bottom: 2px; } |
| 12 | −.docs-nav summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 5px 8px; border-radius: 6px; color: var(--fg-1); font-size: 13.5px; font-weight: 500; } | |
| 13 | +.docs-nav summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 6px; min-height: var(--tap); padding: 4px 8px; border-radius: var(--r-sm); color: var(--fg-1); font-size: var(--fs-1); font-weight: 500; } | |
| 13 | 14 | .docs-nav summary::-webkit-details-marker { display: none; } |
| 14 | 15 | .docs-nav summary:hover { background: var(--bg-2); color: var(--fg); } |
| 15 | 16 | .docs-nav summary svg { width: 14px; height: 14px; transition: transform .15s; color: var(--fg-3); } |
| 16 | 17 | .docs-nav details[open] summary svg { transform: rotate(90deg); } |
| 17 | 18 | .docs-nav details > div { padding-left: 6px; border-left: 1px solid var(--line); margin-left: 12px; } |
| 18 | −.docs-nav-search { display: flex; align-items: center; gap: 8px; width: 100%; margin-bottom: 16px; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); font-size: 13px; cursor: text; text-align: left; } | |
| 19 | +.docs-nav-search { display: flex; align-items: center; gap: 8px; width: 100%; margin-bottom: 16px; min-height: var(--tap); padding: 0 10px; border-radius: var(--r-md); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); font-size: var(--fs-1); cursor: text; text-align: left; } | |
| 19 | 20 | .docs-nav-search kbd { margin-left: auto; } |
| 20 | 21 | .docs-main { min-width: 0; } |
| 21 | −.docpage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); gap: 0; min-height: calc(100dvh - var(--topbar-h)); } | |
| 22 | +.docpage { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; min-height: calc(100dvh - var(--topbar-h)); } | |
| 22 | 23 | .docpage.no-panel { grid-template-columns: minmax(0, 1fr); } |
| 23 | −.docs-content { min-width: 0; padding: 34px 40px 80px; max-width: 820px; } | |
| 24 | +.docs-content { min-width: 0; padding: 34px 40px 80px; max-width: 800px; } | |
| 24 | 25 | .docs-content > header { margin-bottom: 26px; } |
| 25 | 26 | .docs-content h1 { font-size: clamp(26px, 3vw, 34px); margin-bottom: 8px; } |
| 26 | −.docs-crumbs { display: flex; gap: 6px; align-items: center; color: var(--fg-2); font-size: 13px; margin-bottom: 12px; flex-wrap: wrap; } | |
| 27 | +.docs-crumbs { display: flex; gap: 6px; align-items: center; color: var(--fg-2); font-size: var(--fs-1); margin-bottom: 12px; flex-wrap: wrap; } | |
| 27 | 28 | .docs-crumbs a { color: var(--fg-2); } |
| 28 | 29 | .docs-crumbs svg { width: 12px; height: 12px; } |
| 29 | −.docs-panel { border-left: 1px solid var(--line); background: var(--bg-1); padding: 26px 22px 60px; } | |
| 30 | +.docs-panel { border-left: 1px solid var(--line); background: var(--bg-1); padding: 26px 22px 60px; min-width: 0; } | |
| 30 | 31 | .docs-panel-inner { position: sticky; top: calc(var(--topbar-h) + 18px); display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; gap: 18px; max-height: calc(100dvh - var(--topbar-h) - 36px); overflow-y: auto; scrollbar-width: thin; padding-right: 2px; } |
| 31 | 32 | .docs-panel .code { background: var(--code-bg); } |
| 32 | −.docs-panel h4 { font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--fg-3); margin: 0 0 8px; } | |
| 33 | −.panel-block { display: grid; gap: 6px; } | |
| 34 | −.docs-footer { margin-top: 48px; padding-top: 18px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; color: var(--fg-2); font-size: 13px; } | |
| 35 | −.docs-footer a { display: inline-flex; align-items: center; gap: 5px; color: var(--fg-1); } | |
| 33 | +.docs-panel h4, .docs-panel-inline h4 { font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--fg-3); margin: 0 0 8px; } | |
| 34 | +.panel-block { display: grid; gap: 6px; min-width: 0; } | |
| 35 | +.panel-block .code, .panel-block .codetabs { margin: 0; } | |
| 36 | +.docs-panel-inline { display: grid; gap: 16px; margin: 0 0 30px; padding: 18px 16px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); min-width: 0; } | |
| 37 | +.docs-panel-inline .panel-toc { display: none; } | |
| 38 | +.docs-footer { margin-top: 48px; padding-top: 18px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; color: var(--fg-2); font-size: var(--fs-1); } | |
| 39 | +.docs-footer a { display: inline-flex; align-items: center; gap: 5px; min-height: var(--tap); color: var(--fg-1); } | |
| 36 | 40 | .docs-footer svg { width: 14px; height: 14px; } |
| 37 | 41 | .docs-prevnext { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 36px; } |
| 38 | −.docs-prevnext a { display: block; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--fg); } | |
| 42 | +.docs-prevnext a { display: block; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--r-md); color: var(--fg); } | |
| 39 | 43 | .docs-prevnext a:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-1); } |
| 40 | −.docs-prevnext .lbl { display: block; font-size: 12px; color: var(--fg-2); margin-bottom: 4px; } | |
| 44 | +.docs-prevnext .lbl { display: block; font-size: var(--fs-0); color: var(--fg-2); margin-bottom: 4px; } | |
| 41 | 45 | .docs-prevnext .next { text-align: right; } |
| 42 | 46 | |
| 43 | 47 | /* prose (MDX + markdown descriptions) */ |
| 44 | −.prose { font-size: 15.5px; line-height: 1.7; color: var(--fg); overflow-wrap: anywhere; min-width: 0; } | |
| 48 | +.prose { font-size: 16px; line-height: var(--lh-prose); color: var(--fg); overflow-wrap: anywhere; min-width: 0; } | |
| 45 | 49 | .prose > p:first-of-type { color: var(--fg-1); } |
| 46 | −.prose h2 { margin-top: 2.2em; padding-top: .3em; font-size: 22px; } | |
| 47 | −.prose h3 { margin-top: 1.8em; font-size: 17px; } | |
| 48 | −.prose h4 { margin-top: 1.4em; font-size: 15px; } | |
| 50 | +.prose h2 { margin-top: 2.2em; padding-top: .3em; font-size: var(--fs-6); } | |
| 51 | +.prose h3 { margin-top: 1.8em; font-size: var(--fs-4); } | |
| 52 | +.prose h4 { margin-top: 1.4em; font-size: var(--fs-3); } | |
| 49 | 53 | .prose ul, .prose ol { padding-left: 1.4em; margin: 0 0 1em; } |
| 50 | 54 | .prose li + li { margin-top: 5px; } |
| 51 | 55 | .prose li > p { margin: 0; } |
| 52 | −.prose img { border-radius: var(--radius); border: 1px solid var(--line); } | |
| 56 | +.prose img { border-radius: var(--r-md); border: 1px solid var(--line); } | |
| 53 | 57 | .prose .table-wrap { margin: 0 0 20px; } |
| 54 | −.prose table { font-size: 13.5px; } | |
| 58 | +.prose table { font-size: var(--fs-1); } | |
| 55 | 59 | .prose th, .prose td { padding: 8px 11px; } |
| 56 | −.prose blockquote { margin: 0 0 16px; padding: 10px 16px; border-left: 3px solid var(--accent); background: var(--bg-1); border-radius: 0 var(--radius) var(--radius) 0; color: var(--fg-1); } | |
| 60 | +.prose blockquote { margin: 0 0 16px; padding: 10px 16px; border-left: 3px solid var(--accent); background: var(--bg-1); border-radius: 0 var(--r-md) var(--r-md) 0; color: var(--fg-1); } | |
| 57 | 61 | .prose hr { margin: 36px 0; } |
| 58 | 62 | .prose .code, .prose .codetabs, .prose .callout { margin: 16px 0 22px; } |
| 63 | +/* Heading anchors: the "#" is drawn by CSS so it never enters the heading's textContent. */ | |
| 59 | 64 | .anchor-heading { position: relative; } |
| 60 | −.anchor-heading .anchor { position: absolute; left: -1.1em; color: var(--fg-3); opacity: 0; font-weight: 400; padding-right: .3em; } | |
| 61 | −.anchor-heading:hover .anchor, .anchor-heading .anchor:focus { opacity: 1; text-decoration: none; } | |
| 65 | +.anchor-heading .anchor { position: absolute; left: -1.1em; top: 0; bottom: 0; display: inline-flex; align-items: center; color: var(--fg-3); opacity: 0; font-weight: 400; padding-right: .3em; text-decoration: none; } | |
| 66 | +.anchor-heading .anchor::before { content: "#"; } | |
| 67 | +.anchor-heading:hover .anchor, .anchor-heading .anchor:focus-visible { opacity: 1; } | |
| 62 | 68 | .figure { margin: 18px 0 26px; } |
| 63 | 69 | .figure svg { display: block; width: 100%; height: auto; } |
| 64 | −.figure figcaption { font-size: 13px; color: var(--fg-2); margin-top: 8px; text-align: center; } | |
| 65 | −.endpoint-chip { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px 4px 5px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-1); color: var(--fg); margin: 2px 4px 6px 0; font-size: 13px; } | |
| 70 | +.figure figcaption { font-size: var(--fs-1); color: var(--fg-2); margin-top: 8px; text-align: center; } | |
| 71 | +.endpoint-chip { display: inline-flex; align-items: center; gap: 8px; min-height: var(--tap); padding: 0 10px 0 5px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg-1); color: var(--fg); margin: 2px 4px 6px 0; font-size: var(--fs-1); } | |
| 66 | 72 | .endpoint-chip:hover { border-color: var(--line-3); text-decoration: none; } |
| 67 | 73 | .endpoint-chip code { background: none; border: 0; padding: 0; } |
| 68 | 74 | |
| 69 | 75 | /* on this page */ |
| 70 | −.toc a { display: block; padding: 3px 0 3px 10px; border-left: 2px solid var(--line); color: var(--fg-2); font-size: 13px; line-height: 1.4; } | |
| 76 | +.toc a { display: flex; align-items: center; min-height: 32px; padding: 2px 0 2px 10px; border-left: 2px solid var(--line); color: var(--fg-2); font-size: var(--fs-1); line-height: 1.4; } | |
| 71 | 77 | .toc a.depth-3 { padding-left: 22px; } |
| 72 | 78 | .toc a:hover { color: var(--fg); text-decoration: none; } |
| 73 | 79 | .toc a.active { color: var(--fg); border-left-color: var(--accent); } |
| 74 | 80 | |
| 75 | 81 | /* operation page */ |
| 76 | 82 | .op-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 14px; } |
| 77 | −.op-path { font-family: var(--mono); font-size: 15px; word-break: break-all; } | |
| 83 | +.op-path { font-family: var(--mono); font-size: var(--fs-3); word-break: break-all; } | |
| 78 | 84 | .op-path .var { color: var(--accent-2); } |
| 79 | 85 | .op-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0 26px; } |
| 80 | −.param-name { font-family: var(--mono); font-size: 13px; font-weight: 600; } | |
| 86 | +.param-name { font-family: var(--mono); font-size: var(--fs-1); font-weight: 600; } | |
| 81 | 87 | .param-in { font-size: 11px; color: var(--fg-3); margin-left: 6px; text-transform: uppercase; letter-spacing: .04em; } |
| 82 | −.param-type { font-family: var(--mono); font-size: 12px; color: var(--fg-2); } | |
| 88 | +.param-type { font-family: var(--mono); font-size: var(--fs-0); color: var(--fg-2); } | |
| 83 | 89 | .param-req { color: var(--danger); font-size: 11px; font-weight: 600; margin-left: 6px; } |
| 84 | −.param-default { font-family: var(--mono); font-size: 12px; color: var(--fg-2); } | |
| 90 | +.param-default { font-family: var(--mono); font-size: var(--fs-0); color: var(--fg-2); } | |
| 85 | 91 | .param-enum { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; } |
| 86 | 92 | .param-enum code { font-size: 11.5px; } |
| 87 | −.param-desc { color: var(--fg-1); font-size: 13.5px; } | |
| 93 | +.param-desc { color: var(--fg-1); font-size: var(--fs-1); } | |
| 88 | 94 | .param-desc p { margin: 0; } |
| 89 | −.param-constraints { font-size: 12px; color: var(--fg-3); margin-top: 3px; } | |
| 95 | +.param-constraints { font-size: var(--fs-0); color: var(--fg-3); margin-top: 3px; } | |
| 96 | +.params-table td { vertical-align: top; } | |
| 90 | 97 | .errors-list { display: grid; gap: 6px; } |
| 91 | −.error-row { display: grid; grid-template-columns: 52px 1fr; gap: 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; } | |
| 92 | −.error-row code { font-size: 12px; } | |
| 93 | −.headers-list dt { font-family: var(--mono); font-size: 12.5px; font-weight: 600; margin-top: 8px; } | |
| 94 | −.headers-list dd { margin: 2px 0 0; color: var(--fg-1); font-size: 13px; } | |
| 95 | −.source-note { font-size: 12px; color: var(--fg-3); } | |
| 98 | +.error-row { display: grid; grid-template-columns: 52px 1fr; gap: 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: var(--fs-1); } | |
| 99 | +.error-row code { font-size: var(--fs-0); } | |
| 100 | +.headers-list dt { font-family: var(--mono); font-size: var(--fs-0); font-weight: 600; margin-top: 8px; } | |
| 101 | +.headers-list dd { margin: 2px 0 0; color: var(--fg-1); font-size: var(--fs-1); } | |
| 102 | +.source-note { font-size: var(--fs-0); color: var(--fg-3); margin: 0; } | |
| 103 | +.run-live { display: inline-flex; align-items: center; gap: 5px; } | |
| 104 | +.run-live svg { width: 12px; height: 12px; } | |
| 96 | 105 | |
| 97 | 106 | /* reference index */ |
| 98 | 107 | .ref-tag { margin-bottom: 36px; } |
| 99 | 108 | .ref-tag h2 { text-transform: capitalize; display: flex; align-items: baseline; gap: 10px; } |
| 100 | 109 | .ref-op-list { display: grid; gap: 6px; } |
| 101 | −.ref-op { display: grid; grid-template-columns: 56px 1fr; gap: 12px; align-items: baseline; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--fg); } | |
| 110 | +.ref-op { display: grid; grid-template-columns: 56px 1fr; gap: 12px; align-items: baseline; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-md); color: var(--fg); } | |
| 102 | 111 | .ref-op:hover { border-color: var(--line-3); background: var(--bg-1); text-decoration: none; } |
| 103 | −.ref-op .op-path { font-size: 13.5px; } | |
| 104 | −.ref-op .sum { color: var(--fg-2); font-size: 13px; margin-top: 2px; } | |
| 112 | +.ref-op .op-path { font-size: var(--fs-1); } | |
| 113 | +.ref-op .sum { color: var(--fg-2); font-size: var(--fs-1); margin-top: 2px; } | |
| 105 | 114 | |
| 106 | 115 | /* docs home */ |
| 107 | 116 | .docs-home-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 18px 0 30px; } |
| 108 | −.docs-home-card { display: block; padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--radius-lg); color: var(--fg); background: var(--bg-1); } | |
| 117 | +.docs-home-card { display: block; padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--r-lg); color: var(--fg); background: var(--bg-1); } | |
| 109 | 118 | .docs-home-card:hover { border-color: var(--line-3); text-decoration: none; } |
| 110 | −.docs-home-card h3 { font-size: 15px; margin-bottom: 4px; } | |
| 111 | −.docs-home-card p { margin: 0; color: var(--fg-2); font-size: 13.5px; } | |
| 119 | +.docs-home-card h3 { font-size: var(--fs-3); margin-bottom: 4px; } | |
| 120 | +.docs-home-card p { margin: 0; color: var(--fg-2); font-size: var(--fs-1); } | |
| 112 | 121 | |
| 113 | 122 | /* error code page */ |
| 114 | 123 | .error-code-block { padding: 16px 0 22px; border-bottom: 1px solid var(--line); } |
| 115 | −.error-code-block h3 { font-family: var(--mono); font-size: 15px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } | |
| 124 | +.error-code-block h3 { font-family: var(--mono); font-size: var(--fs-3); display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } | |
| 125 | +.error-code-block h3 .anchor { position: static; opacity: 1; padding: 0; color: var(--fg-3); } | |
| 116 | 126 | |
| 117 | 127 | /* mobile nav toggle */ |
| 118 | 128 | .docs-nav-toggle { display: none; } |
| 119 | 129 | |
| 120 | −@media (max-width: 1180px) { | |
| 130 | +@media (max-width: 1199px) { | |
| 121 | 131 | .docpage { grid-template-columns: minmax(0, 1fr); } |
| 122 | − .docs-panel { border-left: 0; border-top: 1px solid var(--line); padding: 22px 40px 50px; } | |
| 123 | − .docs-panel-inner { position: static; max-height: none; overflow: visible; } | |
| 124 | 132 | .docs-content { max-width: 900px; } |
| 125 | 133 | } |
| 126 | −@media (max-width: 900px) { | |
| 134 | +@media (max-width: 960px) { | |
| 127 | 135 | .docs { grid-template-columns: 1fr; } |
| 128 | 136 | .docs-nav { display: none; } |
| 129 | − .docs-nav.open { display: block; position: fixed; inset: var(--topbar-h) 0 0 0; z-index: 45; height: auto; background: var(--bg); background-color: var(--bg); isolation: isolate; border-right: 0; padding: 16px; } | |
| 130 | − .docs-nav-toggle { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--bg-1); position: sticky; top: var(--topbar-h); z-index: 40; font-size: 13.5px; color: var(--fg-1); } | |
| 137 | + .docs-nav.open { display: block; position: fixed; inset: var(--topbar-h) 0 0 0; z-index: calc(var(--z-sticky) + 5); height: auto; background: var(--bg); isolation: isolate; border-right: 0; padding: 16px; } | |
| 138 | + .docs-nav a, .docs-nav summary { min-height: var(--tap-mobile); } | |
| 139 | + .docs-nav-toggle { display: flex; align-items: center; gap: 8px; padding: 6px 16px; border-bottom: 1px solid var(--line); background: var(--bg-1); position: sticky; top: var(--topbar-h); z-index: var(--z-sticky); font-size: var(--fs-1); color: var(--fg-1); min-height: 48px; } | |
| 131 | 140 | .docs-nav-toggle button { display: inline-flex; align-items: center; gap: 6px; } |
| 132 | 141 | .docs-content { padding: 24px 16px 60px; } |
| 133 | − .docs-panel { padding: 18px 16px 40px; } | |
| 142 | + .docs-panel-inline { padding: 14px 12px; } | |
| 134 | 143 | .docs-prevnext { grid-template-columns: 1fr; } |
| 144 | + .docs-prevnext .next { text-align: left; } | |
| 135 | 145 | .docs-home-grid { grid-template-columns: 1fr; } |
| 136 | 146 | .anchor-heading .anchor { display: none; } |
| 147 | + .prose { font-size: 15.5px; } | |
| 148 | + .op-actions .btn { flex: 1 1 auto; } | |
| 149 | +} | |
| 150 | +@media (max-width: 640px) { | |
| 151 | + .error-row { grid-template-columns: 1fr; gap: 4px; } | |
| 152 | + .params-table.table-stack td { grid-template-columns: 84px minmax(0, 1fr); } | |
| 137 | 153 | } |
modified
hfmarketdata/web/src/pages/home/Home.jsx
+212 −171
@@ -1,35 +1,33 @@ | ||
| 1 | −// Homepage: hero, get-a-key steps, live playground preview, integrations, live dataset counters, fundamentals chart. | |
| 2 | −// Every live call degrades gracefully to a static sample; no external requests besides the API itself. | |
| 1 | +// Homepage. Rule: the page makes at most ONE API call on load — GET /v1/status (quota-exempt) — and falls back | |
| 2 | +// silently to the captured copy in content/examples. Everything else (hero response, charts) is real data captured | |
| 3 | +// from production and bundled. Charts load lazily (lightweight-charts chunk) when scrolled into view. | |
| 3 | 4 | import React, { useEffect, useMemo, useRef, useState } from 'react' |
| 4 | 5 | import { Link } from 'react-router-dom' |
| 5 | −import { BASE_URL, CONTACT_EMAIL, HIGH_USAGE_MAILTO, PUBLIC_BASE, api } from '../../app/api.js' | |
| 6 | −import Code, { CopyButton } from '../../components/Code.jsx' | |
| 6 | +import { CONTACT_EMAIL, HIGH_USAGE_MAILTO, PUBLIC_BASE, api } from '../../app/api.js' | |
| 7 | +import BrandLogo from '../../components/BrandLogos.jsx' | |
| 8 | +import CodeBlock from '../../components/CodeBlock.jsx' | |
| 7 | 9 | import CodeTabs from '../../components/CodeTabs.jsx' |
| 10 | +import CopyButton from '../../components/CopyButton.jsx' | |
| 8 | 11 | import { ArrowRightIcon, BoltIcon, DatabaseIcon, KeyIcon, MailIcon, PlayIcon, PulseIcon } from '../../components/Icons.jsx' |
| 12 | +import { Skeleton } from '../../components/States.jsx' | |
| 13 | +import heroExample from '../../../content/examples/bars_v1_bars__asset___ticker__get.json' | |
| 14 | +import statusFallback from '../../../content/examples/status_v1_status_get.json' | |
| 9 | 15 | import { snippetsFor } from '../../docs/snippets.js' |
| 10 | 16 | import useTitle from '../../docs/useTitle.js' |
| 11 | 17 | import './home.css' |
| 12 | 18 | |
| 13 | −const PREVIEW_PATH = '/v1/bars/futures/ES?timeframe=1day&limit=5' | |
| 14 | −// Real response captured from production on 2026-09-04 (first five daily ES continuous bars, contin_UNadj). | |
| 15 | −const PREVIEW_FALLBACK = { count: 5, data: [ | |
| 16 | − { ticker: 'ES', datetime: '2008-01-02', open: 1478.75, high: 1482.75, low: 1449.0, close: 1458.5, volume: 1623508.0, open_interest: 1764399.0 }, | |
| 17 | − { ticker: 'ES', datetime: '2008-01-03', open: 1458.5, high: 1464.75, low: 1451.0, close: 1458.75, volume: 1266237.0, open_interest: 1758867.0 }, | |
| 18 | − { ticker: 'ES', datetime: '2008-01-04', open: 1458.75, high: 1463.25, low: 1417.5, close: 1423.0, volume: 2199479.0, open_interest: 1882526.0 }, | |
| 19 | − { ticker: 'ES', datetime: '2008-01-07', open: 1422.5, high: 1432.25, low: 1410.0, close: 1421.5, volume: 2436934.0, open_interest: 1884889.0 }, | |
| 20 | − { ticker: 'ES', datetime: '2008-01-08', open: 1421.75, high: 1437.75, low: 1393.0, close: 1397.0, volume: 2902242.0, open_interest: 1979612.0 }, | |
| 21 | −] } | |
| 22 | − | |
| 23 | 19 | const fmt = n => (n == null ? '—' : Number(n).toLocaleString('en-US')) |
| 24 | 20 | |
| 25 | 21 | export default function Home() { |
| 26 | 22 | useTitle(null, 'Free open API for high-frequency historical market data: stocks, ETFs, futures contracts, crypto, indices, FX from 1-minute to daily, options chains with Greeks and SEC EDGAR fundamentals since 2010.') |
| 23 | + const status = useStatus() | |
| 27 | 24 | return ( |
| 28 | 25 | <main className="home" data-testid="home"> |
| 29 | 26 | <Hero /> |
| 30 | − <Datasets /> | |
| 31 | − <GetKey /> | |
| 27 | + <Numbers status={status} /> | |
| 28 | + <HowItWorks /> | |
| 32 | 29 | <Preview /> |
| 30 | + <Datasets status={status} /> | |
| 33 | 31 | <Fundamentals /> |
| 34 | 32 | <Integrations /> |
| 35 | 33 | <Closing /> |
@@ -37,122 +35,96 @@ export default function Home() { | ||
| 37 | 35 | ) |
| 38 | 36 | } |
| 39 | 37 | |
| 38 | +/** /v1/status is exempt from the quota; on any failure (offline, 429 on an old server) the captured copy is used silently. */ | |
| 39 | +function useStatus() { | |
| 40 | + const [status, setStatus] = useState({ data: statusFallback.body, live: false }) | |
| 41 | + useEffect(() => { | |
| 42 | + let alive = true | |
| 43 | + api('/v1/status').then(({ data }) => { if (alive && data?.datasets) setStatus({ data, live: true }) }, () => {}) | |
| 44 | + return () => { alive = false } | |
| 45 | + }, []) | |
| 46 | + return status | |
| 47 | +} | |
| 48 | + | |
| 49 | +export function countInstruments(datasets, asset) { | |
| 50 | + const d = datasets?.[asset] | |
| 51 | + if (!d) return null | |
| 52 | + const tf = d['1day'] || d['1min'] || Object.values(d)[0] | |
| 53 | + if (!tf || typeof tf !== 'object') return null | |
| 54 | + return Math.max(...Object.values(tf).map(Number).filter(Number.isFinite)) | |
| 55 | +} | |
| 56 | + | |
| 57 | +// ---------------------------------------------------------------------------------------------------- hero | |
| 40 | 58 | function Hero() { |
| 41 | − const curl = `curl "${PUBLIC_BASE}/v1/bars/stock/AAPL?timeframe=1min&limit=5"` | |
| 59 | + const url = heroExample.url | |
| 60 | + const snippets = useMemo(() => snippetsFor({ url }), [url]) | |
| 61 | + const curl = `curl "${url}"` | |
| 42 | 62 | return ( |
| 43 | 63 | <section className="hero"> |
| 44 | 64 | <div className="hero-inner"> |
| 45 | 65 | <p className="eyebrow">Free · open · no signup to start</p> |
| 46 | − <h1>Open high-frequency market data.<br /><span className="grad">1-minute to daily, since 2010.</span></h1> | |
| 47 | − <p className="lead">Stocks, ETFs, futures — continuous series <em>and</em> every individual contract — crypto, indices and FX, plus full options chains with Greeks and point-in-time SEC fundamentals. One base URL, JSON / CSV / Parquet, generated docs, a playground, and limits that only grow.</p> | |
| 66 | + <h1>Open high-frequency market data.<br />1-minute to daily, since 2010.</h1> | |
| 67 | + <p className="lead">Stocks, ETFs, futures — continuous series and every individual contract — crypto, indices and FX, plus full options chains with Greeks and point-in-time SEC fundamentals. One base URL, JSON / CSV / Parquet, docs generated from the spec, a playground, and limits that only grow.</p> | |
| 48 | 68 | <div className="hero-cta"> |
| 49 | 69 | <Link to="/docs/quickstart" className="btn btn-primary btn-lg">Read the quickstart <ArrowRightIcon /></Link> |
| 50 | 70 | <Link to="/playground" className="btn btn-lg"><PlayIcon /> Open the playground</Link> |
| 51 | 71 | </div> |
| 52 | 72 | <div className="hero-curl" role="group" aria-label="First request"> |
| 53 | 73 | <code>{curl}</code> |
| 54 | − <CopyButton text={curl} /> | |
| 74 | + <CopyButton text={curl} iconOnly label="Copy the curl command" /> | |
| 55 | 75 | </div> |
| 56 | 76 | </div> |
| 57 | − <HeroArt /> | |
| 77 | + <div className="hero-code" aria-label="A real request and its response"> | |
| 78 | + <CodeTabs title="Request" snippets={snippets} maxHeight={200} /> | |
| 79 | + <CodeBlock title={`Response · ${heroExample.status} · captured ${heroExample.captured_at.slice(0, 10)}`} language="json" maxHeight={260} code={JSON.stringify(heroExample.body, null, 2)} data-testid="hero-response" /> | |
| 80 | + </div> | |
| 58 | 81 | </section> |
| 59 | 82 | ) |
| 60 | 83 | } |
| 61 | 84 | |
| 62 | −function HeroArt() { | |
| 63 | − // Decorative candlesticks drawn from the fallback ES bars (real data), scaled — no external asset. | |
| 64 | − const bars = PREVIEW_FALLBACK.data | |
| 65 | − const lo = Math.min(...bars.map(b => b.low)), hi = Math.max(...bars.map(b => b.high)) | |
| 66 | − const y = v => 20 + (1 - (v - lo) / (hi - lo)) * 160 | |
| 67 | − return ( | |
| 68 | − <svg className="hero-art" viewBox="0 0 320 200" aria-hidden="true"> | |
| 69 | − <defs><linearGradient id="hg" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stopColor="var(--accent)" stopOpacity=".35" /><stop offset="1" stopColor="var(--accent)" stopOpacity="0" /></linearGradient></defs> | |
| 70 | − {[0, 1, 2, 3, 4].map(i => <line key={i} x1="0" x2="320" y1={20 + i * 40} y2={20 + i * 40} stroke="var(--line)" />)} | |
| 71 | − {bars.map((b, i) => { | |
| 72 | − const x = 40 + i * 60 | |
| 73 | − const up = b.close >= b.open | |
| 74 | − return ( | |
| 75 | − <g key={b.datetime}> | |
| 76 | − <line x1={x} x2={x} y1={y(b.high)} y2={y(b.low)} stroke={up ? 'var(--accent)' : 'var(--danger)'} strokeWidth="2" /> | |
| 77 | − <rect x={x - 12} width="24" y={y(Math.max(b.open, b.close))} height={Math.max(2, Math.abs(y(b.open) - y(b.close)))} fill={up ? 'var(--accent)' : 'var(--danger)'} rx="2" /> | |
| 78 | − </g> | |
| 79 | − ) | |
| 80 | − })} | |
| 81 | − <path d={`M40 ${y(bars[0].close)} ${bars.map((b, i) => `L${40 + i * 60} ${y(b.close)}`).join(' ')} L280 200 L40 200 Z`} fill="url(#hg)" /> | |
| 82 | − </svg> | |
| 83 | − ) | |
| 84 | −} | |
| 85 | − | |
| 86 | −function useStatus() { | |
| 87 | − const [status, setStatus] = useState(null) | |
| 88 | − useEffect(() => { | |
| 89 | − let alive = true | |
| 90 | − api('/v1/status').then(({ data }) => alive && setStatus(data), () => {}) | |
| 91 | − return () => { alive = false } | |
| 92 | − }, []) | |
| 93 | − return status | |
| 94 | −} | |
| 95 | − | |
| 96 | −export function countInstruments(datasets, asset) { | |
| 97 | − const d = datasets?.[asset] | |
| 98 | − if (!d) return null | |
| 99 | − const tf = d['1day'] || d['1min'] || Object.values(d)[0] | |
| 100 | − if (!tf || typeof tf !== 'object') return null | |
| 101 | − return Math.max(...Object.values(tf).map(Number).filter(Number.isFinite)) | |
| 102 | −} | |
| 103 | − | |
| 104 | −function Datasets() { | |
| 105 | − const status = useStatus() | |
| 106 | − const ds = status?.datasets | |
| 107 | − const cards = [ | |
| 108 | − { k: 'stock', label: 'Stocks', sub: 'US equities · 5 timeframes · 3 adjustments' }, | |
| 109 | − { k: 'etf', label: 'ETFs', sub: 'same variants as stocks' }, | |
| 110 | − { k: 'futures', label: 'Futures roots', sub: 'continuous · unadjusted, ratio, absolute' }, | |
| 111 | − { k: 'futures_contracts', label: 'Futures contracts', sub: 'individual expiries, daily to 1-minute' }, | |
| 112 | − { k: 'crypto', label: 'Crypto pairs', sub: '24 × 7 bars' }, | |
| 113 | − { k: 'index', label: 'Indices', sub: 'equity & volatility indices' }, | |
| 114 | − { k: 'fx', label: 'FX pairs', sub: 'majors, crosses, EM' }, | |
| 85 | +// ---------------------------------------------------------------------------------------------------- numbers | |
| 86 | +function Numbers({ status }) { | |
| 87 | + const ds = status.data?.datasets | |
| 88 | + const quarters = ds?.options?.quarters || [] | |
| 89 | + const firstYear = quarters[0] ? Number(quarters[0].slice(0, 4)) : null | |
| 90 | + const years = firstYear ? new Date().getUTCFullYear() - firstYear : null | |
| 91 | + const items = [ | |
| 92 | + { v: countInstruments(ds, 'stock'), l: 'US stocks', s: '5 timeframes · 3 adjustments' }, | |
| 93 | + { v: countInstruments(ds, 'etf'), l: 'ETFs', s: 'same variants as stocks' }, | |
| 94 | + { v: ds?.futures_contracts?.['1day']?.archive, l: 'Futures contracts', s: `individual expiries · ${fmt(countInstruments(ds, 'futures'))} roots` }, | |
| 95 | + { v: ds?.options?.files_latest_quarter, l: 'Optionable underlyings', s: `${fmt(quarters.length)} quarterly chains with Greeks` }, | |
| 96 | + { v: years != null ? `${years}+` : null, l: 'Years of history', s: firstYear ? `${firstYear} → today · point-in-time fundamentals` : '' }, | |
| 115 | 97 | ] |
| 116 | − const quarters = ds?.options?.quarters?.length | |
| 117 | 98 | return ( |
| 118 | − <section className="section datasets" aria-labelledby="datasets-h"> | |
| 119 | − <div className="section-head"> | |
| 120 | − <p className="eyebrow"><DatabaseIcon /> Datasets</p> | |
| 121 | − <h2 id="datasets-h">What is in the lake — live from <code>/v1/status</code></h2> | |
| 122 | − <p className="muted">Counts refresh from the API each time this page loads. {status ? '' : 'Loading live counts…'}</p> | |
| 123 | − </div> | |
| 124 | − <div className="counters" data-testid="counters"> | |
| 125 | − {cards.map(c => ( | |
| 126 | − <div key={c.k} className="counter"> | |
| 127 | − <div className="counter-n">{ds ? fmt(countInstruments(ds, c.k)) : <span className="skeleton" />}</div> | |
| 128 | − <div className="counter-l">{c.label}</div> | |
| 129 | − <div className="counter-s">{c.sub}</div> | |
| 99 | + <section className="numbers" aria-label="Inventory"> | |
| 100 | + <div className="numbers-inner" data-testid="counters"> | |
| 101 | + {items.map(it => ( | |
| 102 | + <div key={it.l} className="number"> | |
| 103 | + <div className="number-v">{it.v == null ? <Skeleton width={80} height={28} /> : (typeof it.v === 'string' ? it.v : fmt(it.v))}</div> | |
| 104 | + <div className="number-l">{it.l}</div> | |
| 105 | + <div className="number-s">{it.s}</div> | |
| 130 | 106 | </div> |
| 131 | 107 | ))} |
| 132 | − <div className="counter"> | |
| 133 | − <div className="counter-n">{ds ? fmt(quarters) : <span className="skeleton" />}</div> | |
| 134 | − <div className="counter-l">Options quarters</div> | |
| 135 | − <div className="counter-s">end-of-day chains with Greeks{ds?.options?.files_latest_quarter ? ` · ${fmt(ds.options.files_latest_quarter)} underlyings last quarter` : ''}</div> | |
| 136 | − </div> | |
| 137 | 108 | </div> |
| 138 | − <p className="muted small">Full inventory by timeframe and adjustment on the <Link to="/status">status page</Link>.</p> | |
| 109 | + <p className="numbers-note muted small">Counts from <code>GET /v1/status</code>{status.live ? ', live' : ` as captured on ${statusFallback.captured_at.slice(0, 10)}`} · full inventory on the <Link to="/status">status page</Link>.</p> | |
| 139 | 110 | </section> |
| 140 | 111 | ) |
| 141 | 112 | } |
| 142 | 113 | |
| 143 | −function GetKey() { | |
| 114 | +// ---------------------------------------------------------------------------------------------------- how it works | |
| 115 | +function HowItWorks() { | |
| 144 | 116 | return ( |
| 145 | − <section className="section getkey" aria-labelledby="getkey-h"> | |
| 117 | + <section className="section how" aria-labelledby="how-h"> | |
| 146 | 118 | <div className="section-head"> |
| 147 | − <p className="eyebrow"><KeyIcon /> Access</p> | |
| 148 | − <h2 id="getkey-h">Get an API key — or don't</h2> | |
| 119 | + <p className="eyebrow"><KeyIcon /> How it works</p> | |
| 120 | + <h2 id="how-h">Start with a URL. Grow into a key.</h2> | |
| 149 | 121 | <p className="muted">Everything is free. The three levels differ only in how much you can pull per window.</p> |
| 150 | 122 | </div> |
| 151 | 123 | <ol className="steps"> |
| 152 | 124 | <li className="step"> |
| 153 | 125 | <span className="step-n">1</span> |
| 154 | − <h3>Start keyless</h3> | |
| 155 | − <p>Paste a URL. Per IP: <strong>30 requests / hour</strong>, 100 000 rows / hour, 5 000 rows per request. Enough to explore every endpoint.</p> | |
| 126 | + <h3>Paste a URL</h3> | |
| 127 | + <p>No account, no key. Per IP: <strong>30 requests / hour</strong>, 100 000 rows / hour, 5 000 rows per request — enough to explore every endpoint.</p> | |
| 156 | 128 | <Link to="/playground" className="btn btn-sm">Try in the playground</Link> |
| 157 | 129 | </li> |
| 158 | 130 | <li className="step step-featured"> |
@@ -163,83 +135,146 @@ function GetKey() { | ||
| 163 | 135 | </li> |
| 164 | 136 | <li className="step"> |
| 165 | 137 | <span className="step-n">3</span> |
| 166 | − <h3>Ask for higher limits</h3> | |
| 167 | − <p>Building something bigger? E-mail <a href={HIGH_USAGE_MAILTO}>{CONTACT_EMAIL}</a> and your key moves to <strong>600 requests and 10 000 000 rows / minute</strong>. Also free — on request.</p> | |
| 138 | + <h3>Ask for more</h3> | |
| 139 | + <p>Building something bigger? E-mail <a href={HIGH_USAGE_MAILTO}>{CONTACT_EMAIL}</a> and your key moves to <strong>600 requests and 10 000 000 rows / minute</strong>. Still free.</p> | |
| 168 | 140 | <a href={HIGH_USAGE_MAILTO} className="btn btn-sm"><MailIcon /> Request higher limits</a> |
| 169 | 141 | </li> |
| 170 | 142 | </ol> |
| 171 | − <p className="muted small">Parquet counts half, bulk downloads count zero, 304 responses are free. <Link to="/limits">All limits & how they are counted →</Link></p> | |
| 143 | + <p className="muted small">Parquet counts half, bulk downloads count zero, 304 responses are free. <Link to="/limits">All limits and how they are counted →</Link></p> | |
| 172 | 144 | </section> |
| 173 | 145 | ) |
| 174 | 146 | } |
| 175 | 147 | |
| 176 | −function Preview() { | |
| 177 | − const [state, setState] = useState({ body: PREVIEW_FALLBACK, live: false, ms: null, rate: null }) | |
| 148 | +// ---------------------------------------------------------------------------------------------------- chart helpers | |
| 149 | +/** Runs `draw(el, lib)` with lightweight-charts once the element is near the viewport. Returns the container ref. */ | |
| 150 | +function useLazyChart(draw, deps = []) { | |
| 151 | + const ref = useRef(null) | |
| 178 | 152 | useEffect(() => { |
| 179 | − let alive = true | |
| 180 | − api(PREVIEW_PATH).then(({ data, ms, rate }) => alive && setState({ body: data, live: true, ms, rate }), () => {}) | |
| 181 | − return () => { alive = false } | |
| 182 | − }, []) | |
| 183 | − const snippets = useMemo(() => snippetsFor({ url: PUBLIC_BASE + PREVIEW_PATH }), []) | |
| 153 | + const el = ref.current | |
| 154 | + if (!el) return undefined | |
| 155 | + let alive = true, cleanup = null | |
| 156 | + const start = async () => { | |
| 157 | + const lib = await import('lightweight-charts') | |
| 158 | + if (!alive || !ref.current) return | |
| 159 | + cleanup = await draw(ref.current, lib) | |
| 160 | + } | |
| 161 | + const io = typeof IntersectionObserver !== 'undefined' ? new IntersectionObserver(entries => { if (entries.some(e => e.isIntersecting)) { io.disconnect(); start() } }, { rootMargin: '200px' }) : null | |
| 162 | + if (io) io.observe(el); else start() | |
| 163 | + return () => { alive = false; io?.disconnect(); if (typeof cleanup === 'function') cleanup() } | |
| 164 | + }, deps) // eslint-disable-line react-hooks/exhaustive-deps | |
| 165 | + return ref | |
| 166 | +} | |
| 167 | + | |
| 168 | +const cssVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim() | |
| 169 | + | |
| 170 | +function chartOptions({ createChart, LineStyle, ColorType }, el, height) { | |
| 171 | + return createChart(el, { | |
| 172 | + height, autoSize: true, | |
| 173 | + layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: cssVar('--fg-2'), fontFamily: cssVar('--sans'), attributionLogo: true }, | |
| 174 | + grid: { vertLines: { color: cssVar('--line'), style: LineStyle.Dotted }, horzLines: { color: cssVar('--line'), style: LineStyle.Dotted } }, | |
| 175 | + rightPriceScale: { borderVisible: false }, timeScale: { borderVisible: false, fixLeftEdge: true, fixRightEdge: true }, | |
| 176 | + handleScroll: false, handleScale: false, crosshair: { mode: 1 }, | |
| 177 | + }) | |
| 178 | +} | |
| 179 | + | |
| 180 | +// ---------------------------------------------------------------------------------------------------- preview | |
| 181 | +function Preview() { | |
| 182 | + const [meta, setMeta] = useState(null) | |
| 183 | + const ref = useLazyChart(async (el, lib) => { | |
| 184 | + const mod = await import('../../../content/examples/home-aapl-daily.json') | |
| 185 | + const sample = mod.default || mod | |
| 186 | + const rows = sample.rows.map(([time, open, high, low, close]) => ({ time, open, high, low, close })) | |
| 187 | + const chart = chartOptions(lib, el, 300) | |
| 188 | + const series = chart.addCandlestickSeries({ upColor: cssVar('--up'), downColor: cssVar('--down'), wickUpColor: cssVar('--up'), wickDownColor: cssVar('--down'), borderVisible: false }) | |
| 189 | + series.setData(rows) | |
| 190 | + chart.timeScale().fitContent() | |
| 191 | + const onTheme = () => { chart.applyOptions({ layout: { textColor: cssVar('--fg-2') }, grid: { vertLines: { color: cssVar('--line') }, horzLines: { color: cssVar('--line') } } }); series.applyOptions({ upColor: cssVar('--up'), downColor: cssVar('--down'), wickUpColor: cssVar('--up'), wickDownColor: cssVar('--down') }) } | |
| 192 | + window.addEventListener('hfmd:theme', onTheme) | |
| 193 | + const last = rows.at(-1), first = rows[0] | |
| 194 | + setMeta({ n: rows.length, first: first.time, last: last.time, close: last.close, change: (last.close / first.close - 1) * 100, captured: sample.captured_at.slice(0, 10) }) | |
| 195 | + return () => { window.removeEventListener('hfmd:theme', onTheme); chart.remove() } | |
| 196 | + }) | |
| 184 | 197 | return ( |
| 185 | 198 | <section className="section preview" aria-labelledby="preview-h"> |
| 186 | 199 | <div className="section-head"> |
| 187 | 200 | <p className="eyebrow"><PlayIcon /> Playground</p> |
| 188 | − <h2 id="preview-h">A real request, a real response</h2> | |
| 189 | − <p className="muted">Five daily bars of the E-mini S&P 500 continuous series — {state.live ? <>fetched live in <strong>{state.ms} ms</strong>{state.rate?.remainingRequests != null ? <>, {fmt(state.rate.remainingRequests)} keyless requests left this hour</> : ''}</> : 'captured from production (the live call did not complete)'}. Change anything in the <Link to="/playground">playground</Link>.</p> | |
| 190 | − </div> | |
| 191 | − <div className="preview-grid"> | |
| 192 | − <CodeTabs title="Request" snippets={snippets} maxHeight={360} /> | |
| 193 | − <Code title={`Response · 200${state.live ? ' · live' : ' · captured'}`} language="json" maxHeight={360} code={JSON.stringify(state.body, null, 2)} /> | |
| 201 | + <h2 id="preview-h">A real request, a real response — charted</h2> | |
| 202 | + <p className="muted">One year of daily AAPL bars from <code>/v1/bars/stock/AAPL?timeframe=1day</code>, captured from production and drawn with the same chart the playground uses. Change the symbol, the timeframe or the asset class there.</p> | |
| 194 | 203 | </div> |
| 204 | + <figure className="preview-card card"> | |
| 205 | + <figcaption className="preview-cap"> | |
| 206 | + <span><strong>AAPL</strong> · daily · split & dividend adjusted</span> | |
| 207 | + {meta ? <span className="muted small">{meta.first} → {meta.last} · {meta.n} sessions · last close <span className="mono">{meta.close.toFixed(2)}</span> · <span className={meta.change >= 0 ? 'up' : 'down'}>{meta.change >= 0 ? '+' : ''}{meta.change.toFixed(1)} %</span> · captured {meta.captured}</span> : <Skeleton width={260} height={14} />} | |
| 208 | + </figcaption> | |
| 209 | + <div ref={ref} className="chart chart-lg" data-testid="preview-chart" role="img" aria-label="Candlestick chart of one year of daily AAPL bars" /> | |
| 210 | + </figure> | |
| 195 | 211 | <div className="preview-actions"> |
| 196 | − <Link to="/playground?ep=bars_v1_bars__asset___ticker__get&asset=futures&ticker=ES&timeframe=1day&limit=5" className="btn btn-primary">Open this request in the playground <ArrowRightIcon /></Link> | |
| 197 | − <Link to="/docs/reference" className="btn">Browse all endpoints</Link> | |
| 212 | + <Link to="/playground?ep=bars_v1_bars__asset___ticker__get&asset=stock&ticker=AAPL&timeframe=1day&start=2025-09-02&limit=300" className="btn btn-primary">Open this request in the playground <ArrowRightIcon /></Link> | |
| 213 | + <Link to="/playground?ep=bars_v1_bars__asset___ticker__get&asset=futures&ticker=ES&timeframe=1day&limit=5" className="btn">Same for ES futures</Link> | |
| 214 | + <Link to="/docs/reference" className="btn btn-ghost">Browse all endpoints</Link> | |
| 198 | 215 | </div> |
| 199 | 216 | </section> |
| 200 | 217 | ) |
| 201 | 218 | } |
| 202 | 219 | |
| 220 | +// ---------------------------------------------------------------------------------------------------- datasets | |
| 221 | +const DATASET_CARDS = [ | |
| 222 | + { k: 'stock', label: 'Stocks', sub: 'US equities · 1min → 1day · split, split+dividend, unadjusted', ep: 'stock-bars' }, | |
| 223 | + { k: 'etf', label: 'ETFs', sub: 'same variants as stocks', ep: 'stock-bars&asset=etf&ticker=SPY' }, | |
| 224 | + { k: 'futures', label: 'Futures roots', sub: 'continuous: unadjusted, ratio, absolute', ep: 'futures-continuous-legacy' }, | |
| 225 | + { k: 'futures_contracts', label: 'Futures contracts', sub: 'individual expiries, daily to 1-minute', ep: 'futures-contract-bars' }, | |
| 226 | + { k: 'crypto', label: 'Crypto pairs', sub: '24 × 7 bars', ep: 'crypto-bars' }, | |
| 227 | + { k: 'index', label: 'Indices', sub: 'equity and volatility indices', ep: 'index-bars' }, | |
| 228 | + { k: 'fx', label: 'FX pairs', sub: 'majors, crosses, EM', ep: 'fx-bars' }, | |
| 229 | +] | |
| 230 | + | |
| 231 | +function Datasets({ status }) { | |
| 232 | + const ds = status.data?.datasets | |
| 233 | + const q = ds?.options?.quarters | |
| 234 | + return ( | |
| 235 | + <section className="section datasets" aria-labelledby="datasets-h"> | |
| 236 | + <div className="section-head"> | |
| 237 | + <p className="eyebrow"><DatabaseIcon /> Datasets</p> | |
| 238 | + <h2 id="datasets-h">What is in the lake</h2> | |
| 239 | + <p className="muted">Instrument counts per asset class, from <code>/v1/status</code>. Every dataset is available at every access level.</p> | |
| 240 | + </div> | |
| 241 | + <div className="dataset-grid"> | |
| 242 | + {DATASET_CARDS.map(c => { | |
| 243 | + const n = c.k === 'futures_contracts' ? ds?.futures_contracts?.['1day']?.archive : countInstruments(ds, c.k) | |
| 244 | + return ( | |
| 245 | + <Link key={c.k} to={`/playground?ep=${c.ep}`} className="dataset"> | |
| 246 | + <span className="dataset-n">{n == null ? <Skeleton width={70} height={24} /> : fmt(n)}</span> | |
| 247 | + <span className="dataset-l">{c.label}</span> | |
| 248 | + <span className="dataset-s">{c.sub}</span> | |
| 249 | + </Link> | |
| 250 | + ) | |
| 251 | + })} | |
| 252 | + <Link to="/playground?ep=options-chain" className="dataset"> | |
| 253 | + <span className="dataset-n">{q ? fmt(q.length) : <Skeleton width={70} height={24} />}</span> | |
| 254 | + <span className="dataset-l">Options quarters</span> | |
| 255 | + <span className="dataset-s">end-of-day chains with Greeks{q ? ` · ${q[0].slice(0, 4)} → ${q.at(-1).slice(0, 4)}` : ''}</span> | |
| 256 | + </Link> | |
| 257 | + </div> | |
| 258 | + </section> | |
| 259 | + ) | |
| 260 | +} | |
| 261 | + | |
| 262 | +// ---------------------------------------------------------------------------------------------------- fundamentals | |
| 203 | 263 | function Fundamentals() { |
| 204 | − const ref = useRef(null) | |
| 205 | − const [meta, setMeta] = useState({ source: null, points: 0, first: null, last: null, latest: null }) | |
| 206 | − useEffect(() => { | |
| 207 | − let alive = true, chart = null, onResize = null | |
| 208 | − ;(async () => { | |
| 209 | − let points = null, source = null | |
| 210 | − try { | |
| 211 | − const { data } = await api('/v1/fundamentals/AAPL/ratios/daily?from=2015-01-01&limit=50000') | |
| 212 | − const rows = data?.data || [] | |
| 213 | − const pick = r => r.pe ?? r.pe_ratio ?? r.price_to_earnings | |
| 214 | − const pts = rows.filter(r => pick(r) != null).map(r => ({ time: (r.date || r.datetime || '').slice(0, 10), value: Number(pick(r)) })) | |
| 215 | − if (pts.length > 50) { points = pts; source = 'live' } | |
| 216 | − } catch { /* not live yet */ } | |
| 217 | − if (!points) { | |
| 218 | − const mod = await import('../../../content/samples/aapl-pe.json') | |
| 219 | − const sample = mod.default || mod | |
| 220 | − points = sample.points.map(p => ({ time: p.date, value: p.pe })) | |
| 221 | − source = 'sample' | |
| 222 | − } | |
| 223 | − if (!alive || !ref.current) return | |
| 224 | − const { createChart, LineStyle } = await import('lightweight-charts') | |
| 225 | − if (!alive || !ref.current) return | |
| 226 | − const dark = document.documentElement.getAttribute('data-theme') !== 'light' | |
| 227 | − const colors = () => ({ text: dark ? '#aeb6c4' : '#66707f', grid: dark ? '#232833' : '#e2e5eb' }) | |
| 228 | − chart = createChart(ref.current, { | |
| 229 | − height: 260, layout: { background: { color: 'transparent' }, textColor: colors().text, fontFamily: 'inherit', attributionLogo: false }, | |
| 230 | − grid: { vertLines: { color: colors().grid, style: LineStyle.Dotted }, horzLines: { color: colors().grid, style: LineStyle.Dotted } }, | |
| 231 | − rightPriceScale: { borderVisible: false }, timeScale: { borderVisible: false, fixLeftEdge: true, fixRightEdge: true }, | |
| 232 | − handleScroll: false, handleScale: false, crosshair: { mode: 1 }, | |
| 233 | − }) | |
| 234 | − const series = chart.addLineSeries({ color: dark ? '#5ee7a5' : '#0d8a55', lineWidth: 2, priceLineVisible: false, lastValueVisible: true, priceFormat: { type: 'price', precision: 1, minMove: 0.1 } }) | |
| 235 | − series.setData(points) | |
| 236 | − chart.timeScale().fitContent() | |
| 237 | − onResize = () => chart.applyOptions({ width: ref.current.clientWidth }) | |
| 238 | − onResize(); window.addEventListener('resize', onResize) | |
| 239 | − setMeta({ source, points: points.length, first: points[0].time.slice(0, 4), last: points.at(-1).time.slice(0, 4), latest: points.at(-1).value }) | |
| 240 | − })() | |
| 241 | − return () => { alive = false; if (onResize) window.removeEventListener('resize', onResize); chart?.remove() } | |
| 242 | − }, []) | |
| 264 | + const [meta, setMeta] = useState(null) | |
| 265 | + const ref = useLazyChart(async (el, lib) => { | |
| 266 | + const mod = await import('../../../content/samples/aapl-pe.json') | |
| 267 | + const sample = mod.default || mod | |
| 268 | + const points = sample.points.map(p => ({ time: p.date, value: p.pe })) | |
| 269 | + const chart = chartOptions(lib, el, 240) | |
| 270 | + const series = chart.addLineSeries({ color: cssVar('--accent'), lineWidth: 2, priceLineVisible: false, lastValueVisible: true, priceFormat: { type: 'price', precision: 1, minMove: 0.1 } }) | |
| 271 | + series.setData(points) | |
| 272 | + chart.timeScale().fitContent() | |
| 273 | + const onTheme = () => { chart.applyOptions({ layout: { textColor: cssVar('--fg-2') }, grid: { vertLines: { color: cssVar('--line') }, horzLines: { color: cssVar('--line') } } }); series.applyOptions({ color: cssVar('--accent') }) } | |
| 274 | + window.addEventListener('hfmd:theme', onTheme) | |
| 275 | + setMeta({ first: points[0].time.slice(0, 4), last: points.at(-1).time.slice(0, 4), latest: points.at(-1).value }) | |
| 276 | + return () => { window.removeEventListener('hfmd:theme', onTheme); chart.remove() } | |
| 277 | + }) | |
| 243 | 278 | return ( |
| 244 | 279 | <section className="section fundamentals" aria-labelledby="fund-h"> |
| 245 | 280 | <div className="fund-grid"> |
@@ -255,34 +290,39 @@ function Fundamentals() { | ||
| 255 | 290 | </div> |
| 256 | 291 | <figure className="fund-chart card"> |
| 257 | 292 | <figcaption> |
| 258 | − <strong>Apple — P/E, {meta.first || '2015'}–{meta.last || 'today'}</strong> | |
| 259 | − <span className="muted small">{meta.source === 'live' ? <>live from <code>/v1/fundamentals/AAPL/ratios/daily</code></> : meta.source === 'sample' ? <>sample: split-adjusted close ÷ 10-K diluted EPS (live ratios endpoint not reachable)</> : 'loading…'}{meta.latest ? ` · latest ${meta.latest.toFixed(1)}×` : ''}</span> | |
| 293 | + <strong>Apple — price / earnings, {meta?.first || '2015'}–{meta?.last || 'today'}</strong> | |
| 294 | + <span className="muted small">Weekly split-adjusted close ÷ diluted EPS from the latest 10-K{meta?.latest ? ` · latest ${meta.latest.toFixed(1)}×` : ''}</span> | |
| 260 | 295 | </figcaption> |
| 261 | − <div ref={ref} className="chart" data-testid="pe-chart" /> | |
| 296 | + <div ref={ref} className="chart" data-testid="pe-chart" role="img" aria-label="Line chart of Apple's price-to-earnings ratio since 2015" /> | |
| 262 | 297 | </figure> |
| 263 | 298 | </div> |
| 264 | 299 | </section> |
| 265 | 300 | ) |
| 266 | 301 | } |
| 267 | 302 | |
| 303 | +// ---------------------------------------------------------------------------------------------------- integrations | |
| 304 | +const INTEGRATIONS = [ | |
| 305 | + { logo: 'claude', name: 'Claude Code', desc: 'Add the MCP server, ask for data in plain English.', to: '/integrations/claude-code' }, | |
| 306 | + { logo: 'cursor', name: 'Cursor', desc: 'One-line config in .cursor/mcp.json.', to: '/integrations/cursor' }, | |
| 307 | + { logo: 'codex', name: 'Codex', desc: 'config.toml block + skills pack for the CLI.', to: '/integrations/codex' }, | |
| 308 | + { logo: 'mcp', name: 'MCP server', desc: '14 read-only tools, stdio, any MCP client.', to: '/integrations/mcp' }, | |
| 309 | + { logo: 'python', name: 'Python', desc: 'requests + pandas, three lines to a DataFrame.', to: '/docs/quickstart' }, | |
| 310 | + { logo: 'r', name: 'R', desc: 'httr2 and arrow for Parquet.', to: '/docs/quickstart' }, | |
| 311 | + { logo: 'javascript', name: 'JavaScript', desc: 'fetch in the browser or Node.', to: '/docs/quickstart' }, | |
| 312 | +] | |
| 313 | + | |
| 268 | 314 | function Integrations() { |
| 269 | − const items = [ | |
| 270 | − { name: 'Claude Code', desc: 'Add the MCP server and ask for data in plain English.', logo: <svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#d97757" /><path d="M12 28 20 12l8 16h-4l-4-8-4 8z" fill="#fff" /></svg> }, | |
| 271 | − { name: 'Cursor', desc: 'One-line MCP config in .cursor/mcp.json.', logo: <svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#111" /><path d="M20 8l11 6.5v11L20 32 9 25.5v-11z" fill="none" stroke="#fff" strokeWidth="2" /><path d="M20 8v24M9 14.5l11 6.5 11-6.5" stroke="#fff" strokeWidth="1.5" fill="none" /></svg> }, | |
| 272 | − { name: 'Codex', desc: 'Skills pack with ready-made recipes for the CLI.', logo: <svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#0a0c10" stroke="#313847" /><circle cx="20" cy="20" r="9" fill="none" stroke="#fff" strokeWidth="2.2" /><circle cx="20" cy="20" r="3" fill="#fff" /></svg> }, | |
| 273 | − { name: 'MCP', desc: 'Model Context Protocol server, stdio, any compatible client.', logo: <svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="var(--bg-2)" stroke="var(--line-2)" /><path d="M12 26l8-14 8 14M15 22h10" fill="none" stroke="var(--accent)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /></svg> }, | |
| 274 | − ] | |
| 275 | 315 | return ( |
| 276 | 316 | <section className="section integrations" aria-labelledby="int-h"> |
| 277 | 317 | <div className="section-head"> |
| 278 | 318 | <p className="eyebrow"><BoltIcon /> Integrations</p> |
| 279 | 319 | <h2 id="int-h">Works where you already work</h2> |
| 280 | − <p className="muted">An MCP server exposes every endpoint as a tool; a skills pack teaches coding agents the recipes. Install once, ask questions.</p> | |
| 320 | + <p className="muted">An MCP server exposes every endpoint as a tool, a skills pack teaches coding agents the recipes, and every docs page has samples in four languages.</p> | |
| 281 | 321 | </div> |
| 282 | 322 | <div className="int-grid"> |
| 283 | − {items.map(it => ( | |
| 284 | − <Link key={it.name} to="/integrations" className="int-card"> | |
| 285 | − <span className="int-logo">{it.logo}</span> | |
| 323 | + {INTEGRATIONS.map(it => ( | |
| 324 | + <Link key={it.name} to={it.to} className="int-card"> | |
| 325 | + <BrandLogo name={it.logo} size={36} /> | |
| 286 | 326 | <span><strong>{it.name}</strong><span className="muted">{it.desc}</span></span> |
| 287 | 327 | </Link> |
| 288 | 328 | ))} |
@@ -291,12 +331,13 @@ function Integrations() { | ||
| 291 | 331 | ) |
| 292 | 332 | } |
| 293 | 333 | |
| 334 | +// ---------------------------------------------------------------------------------------------------- closing | |
| 294 | 335 | function Closing() { |
| 295 | 336 | return ( |
| 296 | 337 | <section className="section closing"> |
| 297 | 338 | <div className="closing-card"> |
| 298 | 339 | <div> |
| 299 | − <h2>Start with a URL. Grow into a key.</h2> | |
| 340 | + <h2>Free for everyone. Seriously.</h2> | |
| 300 | 341 | <p className="muted">No signup for your first request, a free account when you need throughput, higher limits on request. Documentation generated from the live spec, every example runnable.</p> |
| 301 | 342 | </div> |
| 302 | 343 | <div className="hero-cta"> |
@@ -304,7 +345,7 @@ function Closing() { | ||
| 304 | 345 | <Link to="/signup" className="btn btn-lg">Create free account</Link> |
| 305 | 346 | </div> |
| 306 | 347 | </div> |
| 307 | − <p className="muted small" style={{ textAlign: 'center' }}>API base <code>{BASE_URL || PUBLIC_BASE}/v1</code> · <a href="/openapi.json">openapi.json</a> · <Link to="/status">status</Link></p> | |
| 348 | + <p className="muted small" style={{ textAlign: 'center' }}>API base <code>{PUBLIC_BASE}/v1</code> · <a href="/openapi.json">openapi.json</a> · <Link to="/status">status</Link> · <Link to="/data-license">data license</Link></p> | |
| 308 | 349 | </section> |
| 309 | 350 | ) |
| 310 | 351 | } |
modified
hfmarketdata/web/src/pages/home/home.css
+56 −44
@@ -1,64 +1,68 @@ | ||
| 1 | −.home { flex: 1; } | |
| 1 | +.home { flex: 1; min-width: 0; } | |
| 2 | 2 | .section { max-width: var(--max); margin: 0 auto; padding: 56px 20px; } |
| 3 | 3 | .section-head { max-width: 720px; margin-bottom: 26px; } |
| 4 | −.section-head .eyebrow { display: inline-flex; align-items: center; gap: 6px; } | |
| 5 | −.section-head .eyebrow svg { width: 14px; height: 14px; } | |
| 6 | 4 | .section-head h2 { margin-bottom: 6px; } |
| 7 | 5 | .section-head p { margin: 0; } |
| 8 | 6 | |
| 9 | −/* hero */ | |
| 10 | −.hero { max-width: var(--max); margin: 0 auto; padding: 72px 20px 40px; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, .8fr); gap: 40px; align-items: center; position: relative; } | |
| 11 | −.hero::before { content: ""; position: absolute; inset: -80px -20% auto -20%; height: 420px; background: radial-gradient(60% 60% at 30% 30%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%), radial-gradient(40% 50% at 75% 20%, color-mix(in srgb, var(--accent-2) 12%, transparent), transparent 70%); pointer-events: none; z-index: -1; } | |
| 12 | −.hero h1 { font-size: clamp(34px, 5.2vw, 58px); letter-spacing: -0.03em; margin-bottom: 18px; } | |
| 13 | −.hero .grad { background: linear-gradient(90deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; } | |
| 14 | −.hero .lead { font-size: 18px; } | |
| 7 | +/* hero — 2 columns: copy + real request/response. The glow is clipped by the section (no horizontal overflow). */ | |
| 8 | +.hero { max-width: var(--max); margin: 0 auto; padding: 64px 20px 40px; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); gap: 40px; align-items: center; position: relative; overflow: clip; } | |
| 9 | +.hero::before { content: ""; position: absolute; inset: 0; background: radial-gradient(55% 60% at 20% 20%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%), radial-gradient(40% 50% at 85% 30%, color-mix(in srgb, var(--accent-2) 9%, transparent), transparent 70%); pointer-events: none; z-index: -1; } | |
| 10 | +.hero h1 { font-size: clamp(32px, 4.4vw, 52px); letter-spacing: -0.03em; margin-bottom: 18px; } | |
| 11 | +.hero .lead { font-size: var(--fs-4); } | |
| 15 | 12 | .hero-cta { display: flex; gap: 12px; flex-wrap: wrap; margin: 26px 0 22px; } |
| 16 | 13 | .hero-inner { min-width: 0; } |
| 17 | −.hero-curl { display: flex; align-items: center; gap: 10px; padding: 10px 8px 10px 14px; border: 1px solid var(--line-2); border-radius: 10px; background: var(--code-bg); max-width: 640px; min-width: 0; } | |
| 18 | −.hero-curl code { background: none; border: 0; padding: 0; font-size: 13px; color: var(--code-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } | |
| 19 | −.hero-art { width: 100%; height: auto; } | |
| 14 | +.hero-curl { display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 14px; border: 1px solid var(--line-2); border-radius: var(--r-md); background: var(--code-bg); max-width: 640px; min-width: 0; } | |
| 15 | +.hero-curl code { background: none; border: 0; padding: 0; font-size: var(--fs-1); color: var(--code-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } | |
| 16 | +.hero-code { min-width: 0; display: grid; gap: 12px; } | |
| 17 | +.hero-code .codetabs, .hero-code .code { margin: 0; } | |
| 20 | 18 | |
| 21 | −/* counters */ | |
| 22 | −.counters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } | |
| 23 | −.counter { padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--bg-1); } | |
| 24 | −.counter-n { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.1; min-height: 34px; } | |
| 25 | −.counter-l { font-weight: 600; margin-top: 6px; } | |
| 26 | −.counter-s { color: var(--fg-2); font-size: 12.5px; margin-top: 2px; } | |
| 27 | −.skeleton { display: inline-block; width: 90px; height: 28px; border-radius: 6px; background: linear-gradient(90deg, var(--bg-2), var(--bg-3), var(--bg-2)); background-size: 200% 100%; animation: shimmer 1.2s infinite; } | |
| 28 | −@keyframes shimmer { to { background-position: -200% 0; } } | |
| 19 | +/* numbers band */ | |
| 20 | +.numbers { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--bg-1); } | |
| 21 | +.numbers-inner { max-width: var(--max); margin: 0 auto; padding: 22px 20px 6px; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px 20px; } | |
| 22 | +.number { min-width: 0; padding: 6px 0; } | |
| 23 | +.number-v { font-size: clamp(24px, 2.4vw, var(--fs-7)); font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.1; min-height: 34px; font-family: var(--mono); } | |
| 24 | +.number-l { font-weight: 600; margin-top: 6px; font-size: var(--fs-2); } | |
| 25 | +.number-s { color: var(--fg-2); font-size: var(--fs-0); margin-top: 2px; } | |
| 26 | +.numbers-note { max-width: var(--max); margin: 0 auto; padding: 0 20px 16px; } | |
| 29 | 27 | |
| 30 | 28 | /* steps */ |
| 31 | −.steps { list-style: none; margin: 0 0 14px; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; counter-reset: step; } | |
| 32 | −.step { position: relative; padding: 24px 22px 22px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--bg-1); display: flex; flex-direction: column; gap: 8px; } | |
| 33 | −.step h3 { margin: 0; font-size: 17px; } | |
| 34 | −.step p { margin: 0 0 6px; color: var(--fg-1); font-size: 14.5px; flex: 1; } | |
| 35 | −.step-n { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-2); border: 1px solid var(--line-2); font-family: var(--mono); font-size: 13px; font-weight: 700; color: var(--fg-1); } | |
| 29 | +.steps { list-style: none; margin: 0 0 14px; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } | |
| 30 | +.step { position: relative; padding: 24px 22px 22px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); display: flex; flex-direction: column; gap: 8px; min-width: 0; } | |
| 31 | +.step h3 { margin: 0; font-size: var(--fs-4); } | |
| 32 | +.step p { margin: 0 0 6px; color: var(--fg-1); font-size: var(--fs-2); flex: 1; } | |
| 33 | +.step-n { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-2); border: 1px solid var(--line-2); font-family: var(--mono); font-size: var(--fs-1); font-weight: 700; color: var(--fg-1); } | |
| 36 | 34 | .step-featured { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); } |
| 37 | 35 | .step-featured .step-n { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } |
| 38 | 36 | .step .btn { align-self: flex-start; } |
| 39 | 37 | |
| 40 | 38 | /* preview */ |
| 41 | −.preview-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; } | |
| 42 | −.preview-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; } | |
| 39 | +.preview-card { margin: 0; padding: 14px 16px 8px; } | |
| 40 | +.preview-cap { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: baseline; margin-bottom: 8px; font-size: var(--fs-2); } | |
| 41 | +.chart { width: 100%; height: 240px; min-width: 0; } | |
| 42 | +.chart-lg { height: 300px; } | |
| 43 | +.preview-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; } | |
| 44 | + | |
| 45 | +/* datasets */ | |
| 46 | +.dataset-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } | |
| 47 | +.dataset { display: grid; gap: 2px; padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); color: var(--fg); min-width: 0; } | |
| 48 | +.dataset:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-2); } | |
| 49 | +.dataset-n { font-size: var(--fs-6); font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; font-family: var(--mono); line-height: 1.1; min-height: 28px; } | |
| 50 | +.dataset-l { font-weight: 600; margin-top: 4px; } | |
| 51 | +.dataset-s { color: var(--fg-2); font-size: var(--fs-0); } | |
| 43 | 52 | |
| 44 | 53 | /* fundamentals */ |
| 45 | 54 | .fundamentals { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } |
| 46 | 55 | .fund-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 36px; align-items: center; } |
| 47 | −.fund-grid .eyebrow { display: inline-flex; align-items: center; gap: 6px; } | |
| 48 | −.fund-grid .eyebrow svg { width: 14px; height: 14px; } | |
| 49 | 56 | .fund-list { margin: 16px 0 0; padding-left: 1.1em; color: var(--fg-1); } |
| 50 | 57 | .fund-list li + li { margin-top: 6px; } |
| 51 | 58 | .fund-chart { margin: 0; padding: 16px 16px 8px; } |
| 52 | −.fund-chart figcaption { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: baseline; margin-bottom: 8px; font-size: 14px; } | |
| 53 | −.chart { width: 100%; height: 260px; } | |
| 59 | +.fund-chart figcaption { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: baseline; margin-bottom: 8px; font-size: var(--fs-2); } | |
| 54 | 60 | |
| 55 | 61 | /* integrations */ |
| 56 | 62 | .int-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } |
| 57 | −.int-card { display: flex; gap: 12px; align-items: flex-start; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius-lg); color: var(--fg); background: var(--bg-1); } | |
| 58 | −.int-card:hover { border-color: var(--line-3); text-decoration: none; } | |
| 59 | −.int-card > span:last-child { display: grid; gap: 2px; font-size: 13.5px; } | |
| 60 | −.int-logo { flex: none; width: 40px; height: 40px; } | |
| 61 | −.int-logo svg { width: 40px; height: 40px; } | |
| 63 | +.int-card { display: flex; gap: 12px; align-items: flex-start; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--r-lg); color: var(--fg); background: var(--bg-1); min-width: 0; } | |
| 64 | +.int-card:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-2); } | |
| 65 | +.int-card > span:last-child { display: grid; gap: 2px; font-size: var(--fs-1); min-width: 0; } | |
| 62 | 66 | |
| 63 | 67 | /* closing */ |
| 64 | 68 | .closing-card { display: grid; grid-template-columns: minmax(0, 1.3fr) auto; gap: 24px; align-items: center; padding: 34px; border-radius: 18px; border: 1px solid var(--line); background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--bg-1)), color-mix(in srgb, var(--accent-2) 8%, var(--bg-1))); margin-bottom: 14px; } |
@@ -66,16 +70,24 @@ | ||
| 66 | 70 | .closing-card p { margin: 0; } |
| 67 | 71 | .closing-card .hero-cta { margin: 0; } |
| 68 | 72 | |
| 69 | −@media (max-width: 1000px) { | |
| 70 | − .hero { grid-template-columns: minmax(0, 1fr); padding-top: 48px; } | |
| 71 | − .hero-art { max-width: 520px; } | |
| 72 | − .counters, .int-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 73 | +@media (max-width: 1200px) { | |
| 74 | + .numbers-inner { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 75 | + .int-grid, .dataset-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 76 | +} | |
| 77 | +@media (max-width: 960px) { | |
| 78 | + .hero { grid-template-columns: minmax(0, 1fr); padding-top: 44px; gap: 28px; } | |
| 79 | + .int-grid, .dataset-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 73 | 80 | .steps { grid-template-columns: 1fr; } |
| 74 | − .preview-grid, .fund-grid { grid-template-columns: 1fr; } | |
| 81 | + .fund-grid { grid-template-columns: 1fr; gap: 20px; } | |
| 75 | 82 | .closing-card { grid-template-columns: 1fr; } |
| 76 | 83 | } |
| 77 | −@media (max-width: 560px) { | |
| 78 | − .counters, .int-grid { grid-template-columns: 1fr; } | |
| 84 | +@media (max-width: 640px) { | |
| 85 | + .numbers-inner { grid-template-columns: 1fr 1fr; padding: 16px 16px 4px; } | |
| 86 | + .numbers-note { padding: 0 16px 14px; } | |
| 87 | + .dataset-grid { grid-template-columns: 1fr 1fr; } | |
| 88 | + .int-grid { grid-template-columns: 1fr; } | |
| 79 | 89 | .section { padding: 40px 16px; } |
| 80 | − .hero { padding: 36px 16px 24px; } | |
| 90 | + .hero { padding: 32px 16px 24px; } | |
| 91 | + .chart, .chart-lg { height: 220px; } | |
| 92 | + .closing-card { padding: 24px 20px; } | |
| 81 | 93 | } |
modified
hfmarketdata/web/src/pages/integrations/Integrations.jsx
+19 −9
@@ -2,10 +2,19 @@ | ||
| 2 | 2 | // Sub-routes: /integrations, /integrations/mcp, /claude-code, /cursor, /codex, /skills. |
| 3 | 3 | import React, { createContext, useContext, useMemo, useState } from 'react' |
| 4 | 4 | import { Link, NavLink, Route, Routes } from 'react-router-dom' |
| 5 | −import IntCopyBlock, { IntCopyChip } from '../../components/IntCopyBlock.jsx' | |
| 6 | 5 | import { PUBLIC_BASE, CONTACT_EMAIL } from '../../app/api.js' |
| 6 | +import BrandLogo from '../../components/BrandLogos.jsx' | |
| 7 | +import CodeBlock from '../../components/CodeBlock.jsx' | |
| 8 | +import CopyButton from '../../components/CopyButton.jsx' | |
| 9 | +import useTitle from '../../docs/useTitle.js' | |
| 7 | 10 | import './integrations.css' |
| 8 | 11 | |
| 12 | +// Thin adapters over the shared CodeBlock / CopyButton (the former IntCopyBlock / IntCopyChip API). | |
| 13 | +const IntCopyBlock = ({ code, lines, lang = 'text', title, note, compact = false }) => ( | |
| 14 | + <CodeBlock code={code ?? (lines || []).join('\n')} language={lang} title={title || lang} footer={note} className={compact ? 'int-copy compact' : 'int-copy'} /> | |
| 15 | +) | |
| 16 | +const IntCopyChip = ({ text, label }) => <CopyButton text={text} label={label || text} size="md" variant="secondary" className="int-chip" /> | |
| 17 | + | |
| 9 | 18 | // --------------------------------------------------------------------------------------------- |
| 10 | 19 | // Constants (bump MCP_VERSION together with mcp/package.json and the tarball in public/downloads) |
| 11 | 20 | // --------------------------------------------------------------------------------------------- |
@@ -110,6 +119,7 @@ const SUBNAV = [ | ||
| 110 | 119 | ] |
| 111 | 120 | |
| 112 | 121 | export default function Integrations() { |
| 122 | + useTitle('Integrations — MCP server, Claude Code, Cursor, Codex, skills', 'Use HF Market Data from Claude Code, Cursor, Codex and any MCP client: one MCP server with 14 read-only tools, plus a skills pack with ready-to-run Python.') | |
| 113 | 123 | const [mode, setMode] = useState('tarball') |
| 114 | 124 | const [key, setKey] = useState('') |
| 115 | 125 | const value = useMemo(() => ({ mode, setMode, key, setKey, keyOrPlaceholder: key || KEY_PLACEHOLDER }), [mode, key]) |
@@ -191,11 +201,11 @@ function Overview() { | ||
| 191 | 201 | return ( |
| 192 | 202 | <> |
| 193 | 203 | <section className="int-grid"> |
| 194 | − <Link to="/integrations/mcp" className="card int-card"><h3><span className="int-logo">MCP</span>MCP server</h3><p>What the 14 tools do, how outputs are bounded, resources and prompts, install without npm.</p><span className="go">Read →</span></Link> | |
| 195 | − <Link to="/integrations/claude-code" className="card int-card"><h3><span className="int-logo g">CC</span>Claude Code</h3><p>One <code>claude mcp add</code> command, or a shared <code>.mcp.json</code> for your project.</p><span className="go">Set up →</span></Link> | |
| 196 | − <Link to="/integrations/cursor" className="card int-card"><h3><span className="int-logo b">Cu</span>Cursor</h3><p><code>.cursor/mcp.json</code> snippet and a one-click deeplink.</p><span className="go">Set up →</span></Link> | |
| 197 | − <Link to="/integrations/codex" className="card int-card"><h3><span className="int-logo w">Cx</span>Codex</h3><p><code>~/.codex/config.toml</code> block, ready to paste.</p><span className="go">Set up →</span></Link> | |
| 198 | − <Link to="/integrations/skills" className="card int-card"><h3><span className="int-logo">Sk</span>Skills pack</h3><p>Five Claude skills: analysis, walk-forward backtest, continuous futures, fundamentals screen, term structure.</p><span className="go">Download →</span></Link> | |
| 204 | + <Link to="/integrations/mcp" className="card int-card"><h3><BrandLogo name="mcp" size={28} />MCP server</h3><p>What the 14 tools do, how outputs are bounded, resources and prompts, install without npm.</p><span className="go">Read →</span></Link> | |
| 205 | + <Link to="/integrations/claude-code" className="card int-card"><h3><BrandLogo name="claude" size={28} />Claude Code</h3><p>One <code>claude mcp add</code> command, or a shared <code>.mcp.json</code> for your project.</p><span className="go">Set up →</span></Link> | |
| 206 | + <Link to="/integrations/cursor" className="card int-card"><h3><BrandLogo name="cursor" size={28} />Cursor</h3><p><code>.cursor/mcp.json</code> snippet and a one-click deeplink.</p><span className="go">Set up →</span></Link> | |
| 207 | + <Link to="/integrations/codex" className="card int-card"><h3><BrandLogo name="codex" size={28} />Codex</h3><p><code>~/.codex/config.toml</code> block, ready to paste.</p><span className="go">Set up →</span></Link> | |
| 208 | + <Link to="/integrations/skills" className="card int-card"><h3><BrandLogo name="skills" size={28} />Skills pack</h3><p>Five Claude skills: analysis, walk-forward backtest, continuous futures, fundamentals screen, term structure.</p><span className="go">Download →</span></Link> | |
| 199 | 209 | </section> |
| 200 | 210 | |
| 201 | 211 | <section className="int-section"> |
@@ -249,7 +259,7 @@ function McpPage() { | ||
| 249 | 259 | <h2>Tools</h2> |
| 250 | 260 | <div className="int-table"><table> |
| 251 | 261 | <thead><tr><th>Tool</th><th>What it does</th><th>Endpoint(s)</th></tr></thead> |
| 252 | − <tbody>{TOOLS.map(([n, d, e]) => <tr key={n}><td><code>{n}</code></td><td>{d}</td><td className="mono muted" style={{ fontSize: 12 }}>{e}</td></tr>)}</tbody> | |
| 262 | + <tbody>{TOOLS.map(([n, d, e]) => <tr key={n}><td><code>{n}</code></td><td>{d}</td><td className="mono muted small">{e}</td></tr>)}</tbody> | |
| 253 | 263 | </table></div> |
| 254 | 264 | <p><strong>Resources:</strong> <code>hfmarketdata://status</code> (dataset inventory) · <code>hfmarketdata://limits</code> (rate limits + live headroom — everything is free). <strong>Prompts:</strong> <code>term-structure-analysis</code>, <code>compare-contracts</code>, <code>fundamentals-snapshot</code>.</p> |
| 255 | 265 | <div className="int-callout">The futures v2 and fundamentals endpoints roll out progressively on the API. Until an endpoint is live, the corresponding tool returns a clear <code>404 NOT_FOUND</code> message pointing to <code>hfmarketdata://status</code> — nothing is fabricated.</div> |
@@ -298,7 +308,7 @@ function CursorPage() { | ||
| 298 | 308 | <a href={link} className="btn btn-primary" title="Opens Cursor’s MCP install dialog">Add to Cursor (one click)</a> |
| 299 | 309 | <IntCopyChip text={link} label="Copy deeplink" /> |
| 300 | 310 | </div> |
| 301 | − <p className="muted" style={{ fontSize: 13 }}>Best effort: the deeplink (<code>cursor://anysphere.cursor-deeplink/mcp/install?name=…&config=<base64 JSON></code>) is supported by Cursor ≥ 1.0; if nothing happens, paste the JSON below.</p> | |
| 311 | + <p className="muted small">Best effort: the deeplink (<code>cursor://anysphere.cursor-deeplink/mcp/install?name=…&config=<base64 JSON></code>) is supported by Cursor ≥ 1.0; if nothing happens, paste the JSON below.</p> | |
| 302 | 312 | <ol className="int-steps"> |
| 303 | 313 | <li><strong>Install the server</strong><IntCopyBlock compact lang="bash" title="Terminal" code={installCommand(mode)} /></li> |
| 304 | 314 | <li><strong>Add the config</strong><IntCopyBlock compact lang="json" title=".cursor/mcp.json" code={mcpJson(mode, keyOrPlaceholder)} note="Cursor → Settings → MCP → the server should list 14 tools. Toggle it on for the Agent." /></li> |
@@ -339,7 +349,7 @@ function SkillsPage() { | ||
| 339 | 349 | </div> |
| 340 | 350 | <div className="int-table"><table> |
| 341 | 351 | <thead><tr><th>Skill</th><th>What it does</th><th>Scripts</th></tr></thead> |
| 342 | − <tbody>{SKILLS.map(([n, d, s]) => <tr key={n}><td><code>{n}</code></td><td>{d}</td><td className="mono muted" style={{ fontSize: 12 }}>{s}</td></tr>)}</tbody> | |
| 352 | + <tbody>{SKILLS.map(([n, d, s]) => <tr key={n}><td><code>{n}</code></td><td>{d}</td><td className="mono muted small">{s}</td></tr>)}</tbody> | |
| 343 | 353 | </table></div> |
| 344 | 354 | </section> |
| 345 | 355 | <section className="int-section"> |
modified
hfmarketdata/web/src/pages/integrations/integrations.css
+50 −52
@@ -1,90 +1,88 @@ | ||
| 1 | −/* Integrations area — uses the shell tokens from src/app/theme.css only. Owner: mcp-skills. */ | |
| 2 | −.int-hero { display: grid; grid-template-columns: 1.4fr 1fr; gap: 28px; align-items: start; margin-bottom: 28px; } | |
| 3 | −.int-hero h1 { font-size: 34px; line-height: 1.15; margin: 0 0 10px; letter-spacing: -0.01em; } | |
| 1 | +/* Integrations area — shell tokens only. Owner: mcp-skills (design pass by web-core). */ | |
| 2 | +.int-hero { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 28px; align-items: start; margin-bottom: 28px; } | |
| 3 | +.int-hero h1 { font-size: clamp(28px, 3.2vw, 36px); line-height: 1.15; margin: 0 0 10px; letter-spacing: -0.02em; } | |
| 4 | 4 | .int-hero p.lead { color: var(--fg-1); font-size: 16px; margin: 0 0 18px; max-width: 60ch; } |
| 5 | −.int-kicker { display: inline-flex; gap: 8px; align-items: center; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin-bottom: 10px; } | |
| 6 | −.int-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 4px; } | |
| 5 | +.int-kicker { display: inline-flex; gap: 8px; align-items: center; font-size: var(--fs-0); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin-bottom: 10px; font-weight: 650; } | |
| 6 | +.int-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 4px; align-items: center; } | |
| 7 | 7 | .int-actions .btn { text-decoration: none; } |
| 8 | 8 | .int-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } |
| 9 | −.int-badge { font-size: 12px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line-2); color: var(--fg-1); background: var(--bg-1); } | |
| 9 | +.int-badge { font-size: var(--fs-0); padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line-2); color: var(--fg-1); background: var(--bg-1); } | |
| 10 | 10 | .int-badge.ok { border-color: color-mix(in srgb, var(--accent) 45%, transparent); color: var(--accent); } |
| 11 | 11 | |
| 12 | −.int-demo { border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); overflow: hidden; } | |
| 13 | −.int-demo-bar { display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--line); } | |
| 12 | +/* demo terminal — wraps instead of overflowing */ | |
| 13 | +.int-demo { border: 1px solid var(--line); border-radius: var(--r-md); background: var(--code-bg); overflow: hidden; min-width: 0; } | |
| 14 | +.int-demo-bar { display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--line); background: var(--bg-1); } | |
| 14 | 15 | .int-demo-bar i { width: 10px; height: 10px; border-radius: 50%; background: var(--line-2); display: inline-block; } |
| 15 | −.int-demo pre { margin: 0; border: 0; border-radius: 0; background: transparent; font-size: 12.5px; line-height: 1.55; padding: 14px 16px; min-height: 250px; } | |
| 16 | +.int-demo pre { margin: 0; border: 0; border-radius: 0; background: transparent; font-size: var(--fs-0); line-height: 1.6; padding: 14px 16px; min-height: 250px; white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 16 | 17 | .int-demo .u { color: var(--accent-2); } |
| 17 | 18 | .int-demo .t { color: var(--fg-2); } |
| 18 | −.int-demo .a { color: var(--fg); } | |
| 19 | +.int-demo .a { color: var(--code-fg); } | |
| 19 | 20 | .int-demo .g { color: var(--accent); } |
| 20 | 21 | |
| 21 | −.int-subnav { display: flex; gap: 6px; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 10px; margin: 8px 0 26px; } | |
| 22 | −.int-subnav a { padding: 7px 12px; border-radius: 6px; color: var(--fg-1); font-size: 14px; } | |
| 22 | +.int-subnav { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 8px; margin: 8px 0 26px; } | |
| 23 | +.int-subnav a { display: inline-flex; align-items: center; min-height: var(--tap); padding: 0 12px; border-radius: var(--r-sm); color: var(--fg-1); font-size: var(--fs-2); font-weight: 500; } | |
| 23 | 24 | .int-subnav a.active, .int-subnav a:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; } |
| 24 | 25 | |
| 25 | −.int-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin: 18px 0 28px; } | |
| 26 | −.int-card { display: flex; flex-direction: column; gap: 8px; text-decoration: none; color: inherit; transition: border-color 0.15s; } | |
| 27 | −.int-card:hover { border-color: var(--fg-2); text-decoration: none; } | |
| 28 | −.int-card h3 { margin: 0; font-size: 16px; display: flex; align-items: center; gap: 8px; } | |
| 29 | −.int-card p { margin: 0; color: var(--fg-1); font-size: 14px; } | |
| 30 | −.int-card .go { color: var(--accent-2); font-size: 13px; margin-top: auto; } | |
| 31 | −.int-logo { width: 26px; height: 26px; border-radius: 7px; display: inline-grid; place-items: center; font-family: var(--mono); font-size: 12px; font-weight: 700; color: #05140c; background: var(--accent); } | |
| 32 | −.int-logo.b { background: var(--accent-2); color: #041a2a; } | |
| 33 | −.int-logo.w { background: var(--warn); color: #2a1c00; } | |
| 34 | −.int-logo.g { background: var(--fg-1); color: var(--bg); } | |
| 26 | +/* overview cards: 5 equal columns, 3 + 2 centred on tablets, 1 on phones */ | |
| 27 | +.int-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin: 18px 0 28px; } | |
| 28 | +.int-card { display: flex; flex-direction: column; gap: 8px; text-decoration: none; color: inherit; transition: border-color 0.15s; padding: 16px; min-width: 0; } | |
| 29 | +.int-card:hover { border-color: var(--line-3); background: var(--bg-2); text-decoration: none; } | |
| 30 | +.int-card h3 { margin: 0; font-size: var(--fs-3); display: flex; align-items: center; gap: 10px; } | |
| 31 | +.int-card p { margin: 0; color: var(--fg-1); font-size: var(--fs-1); } | |
| 32 | +.int-card .go { color: var(--accent-2); font-size: var(--fs-1); margin-top: auto; } | |
| 35 | 33 | |
| 36 | −.int-section { margin: 0 0 34px; } | |
| 37 | −.int-section h2 { font-size: 22px; margin: 0 0 6px; } | |
| 38 | −.int-section h3 { font-size: 16px; margin: 22px 0 8px; } | |
| 34 | +.int-section { margin: 0 0 34px; min-width: 0; } | |
| 35 | +.int-section h2 { font-size: var(--fs-6); margin: 0 0 6px; } | |
| 36 | +.int-section h3 { font-size: var(--fs-3); margin: 22px 0 8px; } | |
| 39 | 37 | .int-section p { color: var(--fg-1); max-width: 78ch; } |
| 40 | 38 | .int-steps { counter-reset: s; list-style: none; padding: 0; margin: 12px 0; display: grid; gap: 14px; } |
| 41 | −.int-steps li { position: relative; padding-left: 40px; } | |
| 42 | −.int-steps li::before { counter-increment: s; content: counter(s); position: absolute; left: 0; top: 2px; width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-2); border: 1px solid var(--line-2); color: var(--fg); font-size: 13px; font-weight: 600; } | |
| 39 | +.int-steps li { position: relative; padding-left: 40px; min-width: 0; } | |
| 40 | +.int-steps li::before { counter-increment: s; content: counter(s); position: absolute; left: 0; top: 2px; width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-2); border: 1px solid var(--line-2); color: var(--fg); font-size: var(--fs-1); font-weight: 600; } | |
| 43 | 41 | .int-steps li > strong { display: block; margin-bottom: 6px; } |
| 44 | 42 | |
| 45 | −.int-toggle { display: inline-flex; border: 1px solid var(--line-2); border-radius: 8px; overflow: hidden; margin: 6px 0 12px; } | |
| 46 | −.int-toggle button { font: inherit; font-size: 13px; padding: 6px 12px; background: var(--bg-1); color: var(--fg-1); border: 0; cursor: pointer; } | |
| 43 | +.int-toggle { display: inline-flex; border: 1px solid var(--line-2); border-radius: var(--r-md); overflow: hidden; margin: 6px 0 12px; } | |
| 44 | +.int-toggle button { font: inherit; font-size: var(--fs-1); min-height: var(--tap); padding: 0 12px; background: var(--bg-1); color: var(--fg-1); border: 0; cursor: pointer; } | |
| 47 | 45 | .int-toggle button + button { border-left: 1px solid var(--line-2); } |
| 48 | −.int-toggle button.on { background: var(--bg-2); color: var(--fg); } | |
| 46 | +.int-toggle button.on, .int-toggle button[aria-selected="true"] { background: var(--bg-2); color: var(--fg); } | |
| 49 | 47 | |
| 50 | 48 | .int-field { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 6px 0 14px; } |
| 51 | −.int-field label { font-size: 13px; color: var(--fg-1); } | |
| 52 | −.int-field input { min-width: 280px; font-family: var(--mono); font-size: 13px; } | |
| 53 | −.int-field .hint { font-size: 12px; color: var(--fg-2); } | |
| 49 | +.int-field label { font-size: var(--fs-1); color: var(--fg-1); } | |
| 50 | +.int-field input { min-width: 280px; font-family: var(--mono); font-size: var(--fs-1); } | |
| 51 | +.int-field .hint { font-size: var(--fs-0); color: var(--fg-2); } | |
| 54 | 52 | |
| 55 | −.int-copy { margin: 10px 0 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); overflow: hidden; } | |
| 56 | −.int-copy-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 6px 12px; border-bottom: 1px solid var(--line); background: var(--bg-2); } | |
| 57 | −.int-copy-title { font-size: 12px; color: var(--fg-2); } | |
| 58 | −.int-copy-btn { font: inherit; font-size: 12px; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-1); cursor: pointer; } | |
| 59 | −.int-copy-btn:hover { border-color: var(--fg-2); color: var(--fg); } | |
| 60 | −.int-copy-btn.ok { border-color: var(--accent); color: var(--accent); } | |
| 61 | −.int-copy-pre { margin: 0; border: 0; border-radius: 0; background: transparent; font-size: 13px; line-height: 1.55; padding: 12px 14px; } | |
| 62 | −.int-copy.compact .int-copy-pre { padding: 8px 12px; } | |
| 63 | −.int-copy-note { padding: 6px 12px 10px; font-size: 12.5px; } | |
| 64 | −.int-chip { font: inherit; font-size: 12.5px; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg); cursor: pointer; } | |
| 65 | −.int-chip:hover { border-color: var(--fg-2); } | |
| 66 | −.int-chip.ok { border-color: var(--accent); color: var(--accent); } | |
| 67 | −.int-chip-ico { color: var(--fg-2); } | |
| 53 | +.int-copy { margin: 10px 0 16px; } | |
| 54 | +.int-copy.compact .code-pre { padding: 8px 12px; } | |
| 55 | +.int-chip { align-self: flex-start; } | |
| 68 | 56 | |
| 69 | −.int-table { margin: 12px 0 18px; overflow-x: auto; } | |
| 70 | −.int-table table { font-size: 13.5px; } | |
| 57 | +.int-table { margin: 12px 0 18px; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--r-md); } | |
| 58 | +.int-table table { font-size: var(--fs-1); } | |
| 71 | 59 | .int-table td:first-child { white-space: nowrap; } |
| 72 | 60 | .int-table code { white-space: nowrap; } |
| 61 | +.int-table tr:last-child td { border-bottom: 0; } | |
| 73 | 62 | |
| 74 | 63 | .int-prompts { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin: 10px 0 18px; } |
| 75 | 64 | .int-prompt { padding: 14px; display: flex; flex-direction: column; gap: 8px; } |
| 76 | 65 | .int-prompt q { font-style: italic; color: var(--fg); quotes: '“' '”'; } |
| 77 | 66 | .int-prompt small { color: var(--fg-2); } |
| 78 | 67 | |
| 79 | −.int-callout { border-left: 3px solid var(--accent-2); padding: 10px 14px; background: var(--bg-1); border-radius: 0 var(--radius) var(--radius) 0; margin: 14px 0; color: var(--fg-1); font-size: 14px; } | |
| 68 | +.int-callout { border-left: 3px solid var(--info); padding: 10px 14px; background: var(--bg-1); border-radius: 0 var(--r-md) var(--r-md) 0; margin: 14px 0; color: var(--fg-1); font-size: var(--fs-2); } | |
| 80 | 69 | .int-callout.warn { border-left-color: var(--warn); } |
| 81 | 70 | .int-dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin: 14px 0 22px; } |
| 82 | 71 | .int-dl .card { display: flex; flex-direction: column; gap: 6px; } |
| 83 | −.int-dl .card .size { color: var(--fg-2); font-size: 12.5px; } | |
| 72 | +.int-dl .card .size { color: var(--fg-2); font-size: var(--fs-0); } | |
| 84 | 73 | .int-dl .btn { align-self: flex-start; margin-top: 4px; } |
| 85 | 74 | |
| 86 | −@media (max-width: 860px) { | |
| 75 | +@media (max-width: 1100px) { | |
| 76 | + .int-grid { display: flex; flex-wrap: wrap; justify-content: center; } | |
| 77 | + .int-grid .int-card { flex: 0 1 calc(33.333% - 8px); min-width: 240px; } | |
| 78 | +} | |
| 79 | +@media (max-width: 960px) { | |
| 87 | 80 | .int-hero { grid-template-columns: 1fr; } |
| 88 | − .int-hero h1 { font-size: 28px; } | |
| 89 | 81 | .int-field input { min-width: 0; width: 100%; } |
| 82 | + .int-actions .btn { flex: 1 1 auto; } | |
| 83 | +} | |
| 84 | +@media (max-width: 640px) { | |
| 85 | + .int-grid { display: grid; grid-template-columns: 1fr; } | |
| 86 | + .int-toggle { display: flex; } | |
| 87 | + .int-toggle button { flex: 1; min-height: var(--tap-mobile); } | |
| 90 | 88 | } |
added
hfmarketdata/web/src/pages/legal/Legal.jsx
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +// /terms · /privacy · /data-license — sober MDX pages. One chunk per page; the MDX components map is the docs one. | |
| 2 | +import React, { Suspense, lazy, useMemo } from 'react' | |
| 3 | +import { MDXProvider } from '@mdx-js/react' | |
| 4 | +import { Link } from 'react-router-dom' | |
| 5 | +import { mdxComponents } from '../../docs/mdx-components.jsx' | |
| 6 | +import useTitle from '../../docs/useTitle.js' | |
| 7 | +import './legal.css' | |
| 8 | + | |
| 9 | +export const LEGAL_PAGES = { | |
| 10 | + terms: { title: 'Terms of use', description: 'Terms of use of the HF Market Data API and website — a free platform, offered as is, with reasonable-use limits.', load: () => import('./terms.mdx') }, | |
| 11 | + privacy: { title: 'Privacy', description: 'What HF Market Data stores about you (account e-mail, hashed keys, request counters), what it does not (no third-party tracking), and how to delete it.', load: () => import('./privacy.mdx') }, | |
| 12 | + 'data-license': { title: 'Data license & attribution', description: 'Where the data comes from (FirstRate Data, SEC EDGAR), what you may do with it, and how to attribute it.', load: () => import('./data-license.mdx') }, | |
| 13 | +} | |
| 14 | + | |
| 15 | +const UPDATED = '2026-09-06' | |
| 16 | + | |
| 17 | +export default function Legal({ page }) { | |
| 18 | + const meta = LEGAL_PAGES[page] | |
| 19 | + useTitle(meta?.title, meta?.description) | |
| 20 | + const Content = useMemo(() => (meta ? lazy(meta.load) : null), [meta]) | |
| 21 | + if (!meta) return null | |
| 22 | + return ( | |
| 23 | + <main className="page narrow legal" data-testid={`legal-${page}`}> | |
| 24 | + <nav className="legal-nav" aria-label="Legal pages"> | |
| 25 | + {Object.entries(LEGAL_PAGES).map(([slug, p]) => <Link key={slug} to={`/${slug}`} className={slug === page ? 'active' : ''} aria-current={slug === page ? 'page' : undefined}>{p.title}</Link>)} | |
| 26 | + </nav> | |
| 27 | + <header className="legal-head"> | |
| 28 | + <p className="eyebrow">Legal</p> | |
| 29 | + <h1>{meta.title}</h1> | |
| 30 | + <p className="muted small">Last updated {UPDATED} · Applies to <code>www.hfmarketdata.io</code> and its API.</p> | |
| 31 | + </header> | |
| 32 | + <article className="prose legal-body"> | |
| 33 | + <Suspense fallback={<p className="muted" aria-busy="true">Loading…</p>}> | |
| 34 | + <MDXProvider components={mdxComponents}><Content /></MDXProvider> | |
| 35 | + </Suspense> | |
| 36 | + </article> | |
| 37 | + </main> | |
| 38 | + ) | |
| 39 | +} | |
added
hfmarketdata/web/src/pages/legal/data-license.mdx
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +HF Market Data redistributes data from two sources. They come with different licences, and this page tells you what you may do with each — and how to give credit. | |
| 2 | + | |
| 3 | +## Sources | |
| 4 | + | |
| 5 | +| Dataset | Source | Coverage | | |
| 6 | +| --- | --- | --- | | |
| 7 | +| Stocks, ETFs, indices, FX, crypto bars (1-minute to daily) | [FirstRate Data](https://firstratedata.com) | since ~2007 / 2010 depending on the instrument | | |
| 8 | +| Futures — continuous series and individual contracts | FirstRate Data | 142 roots, since 2010 | | |
| 9 | +| Options chains with Greeks (end of day) | FirstRate Data | quarterly archives since 2010 Q1 | | |
| 10 | +| Fundamentals, filings, ratios, screener | [SEC EDGAR](https://www.sec.gov/edgar) (XBRL financial data) | since 2010, point-in-time | | |
| 11 | + | |
| 12 | +## FirstRate Data (market data) | |
| 13 | + | |
| 14 | +The bars, futures and options datasets are licensed by their owner, FirstRate Data, and redistributed here for **personal, academic and internal business use** — research, backtesting, teaching, building tools that consume the API. | |
| 15 | + | |
| 16 | +You may **not**: | |
| 17 | + | |
| 18 | +- redistribute the raw data in bulk as a dataset, feed or download service, whether free or paid; | |
| 19 | +- resell or sublicense the data; | |
| 20 | +- present it as your own data product. | |
| 21 | + | |
| 22 | +You may publish charts, statistics, models and derived results built from the data, with attribution. If you are unsure whether your use is covered, ask us or FirstRate Data. | |
| 23 | + | |
| 24 | +## SEC EDGAR (fundamentals) | |
| 25 | + | |
| 26 | +Filings and XBRL financial data published by the U.S. Securities and Exchange Commission are in the **public domain**. The fundamentals endpoints (statements, facts, ratios, screener, frames, filings stream) reproduce that data as filed, with the filing date on every number. Ratios are computed by us from those filings and daily closes; their formulas are documented on [All ratio formulas](/docs/fundamentals/ratios). | |
| 27 | + | |
| 28 | +## Attribution | |
| 29 | + | |
| 30 | +When you publish something built on this API, a line like the following is appreciated (and, for FirstRate Data, required): | |
| 31 | + | |
| 32 | +> Market data: FirstRate Data via HF Market Data (hfmarketdata.io). Fundamentals: SEC EDGAR. | |
| 33 | + | |
| 34 | +## Accuracy and warranty | |
| 35 | + | |
| 36 | +Data is provided as is, without warranty of accuracy, completeness or fitness for any purpose. Errors and gaps exist; missing values are returned as `null` and never estimated. Nothing here is investment advice. | |
| 37 | + | |
| 38 | +## Software | |
| 39 | + | |
| 40 | +The MCP server and skills pack published on [Integrations](/integrations) are released under the MIT licence. The API's OpenAPI specification (`/openapi.json`) may be used freely to generate clients. | |
| 41 | + | |
| 42 | +## Questions | |
| 43 | + | |
| 44 | +[contact@spboucher.ai](mailto:contact@spboucher.ai) | |
added
hfmarketdata/web/src/pages/legal/legal.css
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +.legal-nav { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 22px; border-bottom: 1px solid var(--line); padding-bottom: 8px; } | |
| 2 | +.legal-nav a { display: inline-flex; align-items: center; min-height: var(--tap); padding: 0 12px; border-radius: var(--r-sm); color: var(--fg-1); font-size: var(--fs-2); font-weight: 500; } | |
| 3 | +.legal-nav a.active, .legal-nav a:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; } | |
| 4 | +.legal-head { margin-bottom: 26px; } | |
| 5 | +.legal-head h1 { font-size: clamp(var(--fs-7), 3.5vw, 40px); } | |
| 6 | +.legal-body { font-size: 16px; line-height: var(--lh-prose); } | |
| 7 | +.legal-body h2 { font-size: var(--fs-5); margin-top: 2em; } | |
| 8 | +.legal-body h3 { font-size: var(--fs-4); margin-top: 1.6em; } | |
| 9 | +.legal-body ul, .legal-body ol { padding-left: 1.3em; } | |
| 10 | +.legal-body li + li { margin-top: 6px; } | |
| 11 | +.legal-body .anchor-heading .anchor { display: none; } | |
added
hfmarketdata/web/src/pages/legal/privacy.mdx
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +This page describes exactly what HF Market Data stores about you. The short version: an e-mail address if you create an account, hashed API keys, request counters, and nothing for advertising. | |
| 2 | + | |
| 3 | +## Without an account | |
| 4 | + | |
| 5 | +- **Rate limiting.** Keyless requests are metered per IP address. The IP and a request/row counter live in Redis for the length of the sliding window (one hour) and then expire. No per-request log tied to your IP is kept beyond standard, short-lived server logs used for debugging incidents. | |
| 6 | +- **No tracking.** The website loads no third-party script, font, analytics or advertising pixel. Everything is served from `www.hfmarketdata.io`. | |
| 7 | +- **Browser storage.** The site keeps your theme (`hfmd.theme`), preferred code language (`hfmd.lang`), code-wrap preference and a cached copy of the OpenAPI specification in `localStorage`. These never leave your browser. | |
| 8 | + | |
| 9 | +## With an account | |
| 10 | + | |
| 11 | +- **What we store:** your e-mail address, a password hash (never the password), the display name you choose, the SHA-256 hash and prefix of each API key (the full key is shown once and not retained), your access level, and creation / last-used timestamps. | |
| 12 | +- **Usage counters:** per key and per day, the number of requests and rows served, and per-endpoint counts, so the dashboard can show your consumption and so we can apply limits fairly. These are aggregates, not a log of individual requests or of the symbols you looked at. | |
| 13 | +- **Session cookie.** Signing in sets an `HttpOnly`, `Secure`, `SameSite` session cookie. It is strictly necessary for the dashboard and is not used for anything else. | |
| 14 | +- **Transactional e-mail.** Verification, password-reset and invitation e-mails are sent through [Resend](https://resend.com). Your address is shared with them only to deliver those messages. We send no newsletters. | |
| 15 | + | |
| 16 | +## What we do not do | |
| 17 | + | |
| 18 | +- No selling, renting or sharing of personal data. | |
| 19 | +- No third-party analytics, no advertising, no fingerprinting. | |
| 20 | +- No profiling of the data you query. | |
| 21 | + | |
| 22 | +## Your rights | |
| 23 | + | |
| 24 | +You can view and change your e-mail and name in the dashboard, rotate or revoke keys at any time, and delete your account (all account data is removed; aggregated, anonymous usage totals may remain). For any request — access, correction, deletion — e-mail [contact@spboucher.ai](mailto:contact@spboucher.ai). Data is hosted in Canada. | |
| 25 | + | |
| 26 | +## Contact | |
| 27 | + | |
| 28 | +Simon-Pierre Boucher — [contact@spboucher.ai](mailto:contact@spboucher.ai). | |
added
hfmarketdata/web/src/pages/legal/terms.mdx
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +HF Market Data ("the service") is a free, open API and website for historical market data, run by Simon-Pierre Boucher. Using the service — with or without an account — means you accept these terms. They are short on purpose; if something is unclear, write to [contact@spboucher.ai](mailto:contact@spboucher.ai). | |
| 2 | + | |
| 3 | +## 1. What the service is | |
| 4 | + | |
| 5 | +- A read-only HTTP API under `https://www.hfmarketdata.io/v1`, a WebSocket stream, documentation, a playground and a dashboard for API keys. | |
| 6 | +- Everything is **free**. There is no paid plan. Access levels differ only by rate limits (see [Limits](/limits)): keyless per IP, free account with an API key, and higher limits granted on request by e-mail. | |
| 7 | +- The service is provided **as is**, on a best-effort basis, by one person, on dedicated hardware. There is no service-level agreement, no uptime guarantee and no scheduled maintenance window. | |
| 8 | + | |
| 9 | +## 2. Accounts and API keys | |
| 10 | + | |
| 11 | +- An account needs a working e-mail address, which we verify. You are responsible for what is done with your API keys; keep them out of public code and rotate them from the dashboard if they leak. | |
| 12 | +- One person, one account. Sharing a key to circumvent limits, or creating several accounts for the same purpose, may lead to the keys being revoked. | |
| 13 | +- We may suspend keys that break the service for others (abusive traffic, scraping the whole lake through the row quota, attempts to bypass rate limiting). | |
| 14 | + | |
| 15 | +## 3. Reasonable use | |
| 16 | + | |
| 17 | +The limits are generous and grow on request. Please do not: | |
| 18 | + | |
| 19 | +- run automated retry storms against `429` responses — honour `Retry-After`; | |
| 20 | +- redistribute the raw data as a competing data feed or resell it (see [Data license & attribution](/data-license)); | |
| 21 | +- probe, disrupt or reverse-engineer the service's infrastructure. | |
| 22 | + | |
| 23 | +If you need more than the free account gives you, ask — higher limits are also free. | |
| 24 | + | |
| 25 | +## 4. Data and accuracy | |
| 26 | + | |
| 27 | +- Market data comes from FirstRate Data; fundamentals come from SEC EDGAR. We pass it through without alteration beyond format conversion and documented adjustments (split / dividend adjustment, continuous-contract construction). | |
| 28 | +- Data may contain errors, gaps or delays. **Missing values are returned as `null`, never filled in.** Nothing on this service is investment advice; you are solely responsible for decisions made with it. | |
| 29 | +- We may add endpoints and fields at any time. Existing `/v1` endpoints keep their paths, parameters and response shapes; changes are announced in the [changelog](/docs/changelog) under the [versioning policy](/docs/versioning). | |
| 30 | + | |
| 31 | +## 5. Availability and termination | |
| 32 | + | |
| 33 | +We may change, suspend or stop the service, in whole or in part, at any time. We will try to give notice through the changelog and the status page. You may delete your account at any time from the dashboard or by e-mail. | |
| 34 | + | |
| 35 | +## 6. Liability | |
| 36 | + | |
| 37 | +To the fullest extent permitted by law, the service is provided without warranty of any kind, express or implied, and its operator is not liable for any loss or damage arising from its use or unavailability, including trading losses. | |
| 38 | + | |
| 39 | +## 7. Governing law | |
| 40 | + | |
| 41 | +These terms are governed by the laws of Québec and the federal laws of Canada applicable therein. Any dispute will be brought before the courts of the judicial district of Gatineau, Québec. | |
| 42 | + | |
| 43 | +## 8. Changes to these terms | |
| 44 | + | |
| 45 | +We may update these terms; the date at the top of the page changes when we do. Continued use after a change means you accept the new terms. | |
modified
hfmarketdata/web/src/pages/limits/Limits.jsx
+1 −1
@@ -3,7 +3,7 @@ import React from 'react' | ||
| 3 | 3 | import { Link } from 'react-router-dom' |
| 4 | 4 | import { CONTACT_EMAIL, HIGH_USAGE_MAILTO, TIERS } from '../../app/api.js' |
| 5 | 5 | import Callout from '../../components/Callout.jsx' |
| 6 | −import Code from '../../components/Code.jsx' | |
| 6 | +import Code from '../../components/CodeBlock.jsx' | |
| 7 | 7 | import { BoltIcon, CheckIcon, KeyIcon, MailIcon } from '../../components/Icons.jsx' |
| 8 | 8 | import useTitle from '../../docs/useTitle.js' |
| 9 | 9 | import './limits.css' |
modified
hfmarketdata/web/src/pages/limits/limits.css
+15 −11
@@ -2,17 +2,17 @@ | ||
| 2 | 2 | .limits-head h1 { font-size: clamp(34px, 5vw, 52px); } |
| 3 | 3 | .limits-cta { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 22px; } |
| 4 | 4 | .tiers { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 14px; } |
| 5 | −.tier { display: flex; flex-direction: column; gap: 18px; padding: 24px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--bg-1); } | |
| 6 | −.tier-featured { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent), var(--shadow); position: relative; } | |
| 5 | +.tier { display: flex; flex-direction: column; gap: 18px; padding: 24px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); min-width: 0; } | |
| 6 | +.tier-featured { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent), var(--shadow-2); position: relative; } | |
| 7 | 7 | .tier-featured::before { content: "Most people need this"; position: absolute; top: -11px; left: 20px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; background: var(--accent); color: var(--accent-ink); padding: 3px 9px; border-radius: 999px; } |
| 8 | −.tier header h3 { font-size: 20px; margin-bottom: 2px; } | |
| 9 | −.tier header p { margin: 0; font-size: 13px; } | |
| 8 | +.tier header h3 { font-size: var(--fs-5); margin-bottom: 2px; } | |
| 9 | +.tier header p { margin: 0; font-size: var(--fs-1); } | |
| 10 | 10 | .tier-numbers { display: grid; gap: 10px; margin: 0; } |
| 11 | 11 | .tier-numbers div { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--line); } |
| 12 | −.tier-numbers dt { font-size: 13px; color: var(--fg-2); } | |
| 12 | +.tier-numbers dt { font-size: var(--fs-1); color: var(--fg-2); } | |
| 13 | 13 | .tier-numbers dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; } |
| 14 | −.tier-numbers dd strong { font-size: 16px; font-weight: 650; } | |
| 15 | −.tier-numbers dd span { display: block; font-size: 12px; color: var(--fg-3); } | |
| 14 | +.tier-numbers dd strong { font-size: 16px; font-weight: 650; font-family: var(--mono); } | |
| 15 | +.tier-numbers dd span { display: block; font-size: var(--fs-0); color: var(--fg-3); } | |
| 16 | 16 | .tier footer { margin-top: auto; } |
| 17 | 17 | .tier footer .btn { width: 100%; } |
| 18 | 18 | .limits-incentives { margin: 36px 0; } |
@@ -21,19 +21,23 @@ | ||
| 21 | 21 | .limits-headers { margin: 40px 0; } |
| 22 | 22 | .limits-faq { margin: 48px 0; max-width: 820px; } |
| 23 | 23 | .faq details { border-bottom: 1px solid var(--line); } |
| 24 | −.faq summary { padding: 14px 4px; cursor: pointer; font-weight: 550; list-style: none; display: flex; justify-content: space-between; gap: 12px; } | |
| 24 | +.faq summary { min-height: var(--tap-mobile); padding: 10px 4px; cursor: pointer; font-weight: 550; list-style: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; } | |
| 25 | 25 | .faq summary::-webkit-details-marker { display: none; } |
| 26 | −.faq summary::after { content: "+"; color: var(--fg-3); font-weight: 400; } | |
| 26 | +.faq summary::after { content: "+"; color: var(--fg-3); font-weight: 400; font-size: 18px; } | |
| 27 | 27 | .faq details[open] summary::after { content: "–"; } |
| 28 | 28 | .faq p { margin: 0 4px 16px; color: var(--fg-1); max-width: 70ch; } |
| 29 | 29 | .limits-final-card { display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: center; padding: 26px; } |
| 30 | 30 | .limits-final-card > svg { width: 34px; height: 34px; color: var(--accent); } |
| 31 | 31 | .limits-final-card h3 { margin-bottom: 4px; } |
| 32 | 32 | .limits-final-card p { margin: 0; } |
| 33 | −.checks { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; } | |
| 33 | +.checks { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: var(--fs-2); } | |
| 34 | 34 | .checks li { display: flex; align-items: center; gap: 8px; white-space: nowrap; } |
| 35 | 35 | .checks svg { width: 16px; height: 16px; color: var(--accent); } |
| 36 | −@media (max-width: 900px) { | |
| 36 | +@media (max-width: 960px) { | |
| 37 | 37 | .tiers { grid-template-columns: 1fr; } |
| 38 | 38 | .limits-final-card { grid-template-columns: 1fr; } |
| 39 | + .checks li { white-space: normal; } | |
| 40 | +} | |
| 41 | +@media (max-width: 640px) { | |
| 42 | + .limits-cta .btn { width: 100%; white-space: normal; text-align: center; } | |
| 39 | 43 | } |
modified
hfmarketdata/web/src/pages/playground/Playground.jsx
+1 −1
@@ -7,7 +7,7 @@ export default function PlaygroundPage() { | ||
| 7 | 7 | <main className="page pg-page"> |
| 8 | 8 | <header className="pg-page-head"> |
| 9 | 9 | <h1>Playground</h1> |
| 10 | − <p className="muted">Build a request, run it against the live API, inspect the result as JSON, table or chart, and export the code.</p> | |
| 10 | + <p className="muted">Pick an endpoint, edit the parameters, run it against the live API and inspect the result as JSON, table or chart — then copy the code.</p> | |
| 11 | 11 | </header> |
| 12 | 12 | <PlaygroundApp /> |
| 13 | 13 | </main> |
modified
hfmarketdata/web/src/pages/status/Status.jsx
+66 −38
@@ -1,9 +1,13 @@ | ||
| 1 | −// /status — API health + live dataset inventory (assets × timeframes × adjustments) from /v1/status and /health. | |
| 2 | −import React, { useEffect, useState } from 'react' | |
| 1 | +// /status — API health + live dataset inventory. /v1/status and /health are quota-exempt; a 429 (old server, shared | |
| 2 | +// egress) is reported as "rate limited — not down", never as an outage. Last data dates are shown only when the API | |
| 3 | +// provides them (never invented). | |
| 4 | +import React, { useCallback, useEffect, useState } from 'react' | |
| 3 | 5 | import { Link } from 'react-router-dom' |
| 4 | −import { CONTACT_EMAIL, PUBLIC_BASE, api } from '../../app/api.js' | |
| 6 | +import { BASE_URL, CONTACT_EMAIL, PUBLIC_BASE, api } from '../../app/api.js' | |
| 5 | 7 | import Badge from '../../components/Badge.jsx' |
| 6 | 8 | import Callout from '../../components/Callout.jsx' |
| 9 | +import { Skeleton, Stat } from '../../components/States.jsx' | |
| 10 | +import Table from '../../components/Table.jsx' | |
| 7 | 11 | import useTitle from '../../docs/useTitle.js' |
| 8 | 12 | import './status.css' |
| 9 | 13 | |
@@ -11,61 +15,75 @@ const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day'] | ||
| 11 | 15 | const ASSET_LABEL = { stock: 'Stocks', etf: 'ETFs', futures: 'Futures (continuous)', futures_contracts: 'Futures contracts', crypto: 'Crypto', index: 'Indices', fx: 'FX' } |
| 12 | 16 | const fmt = n => (n == null ? '—' : Number(n).toLocaleString('en-US')) |
| 13 | 17 | |
| 18 | +/** 'loading' | 'ok' | 'limited' (429) | 'down' */ | |
| 19 | +function classify(e) { return e?.status === 429 ? 'limited' : 'down' } | |
| 20 | + | |
| 14 | 21 | export default function Status() { |
| 15 | 22 | useTitle('Status', 'Live health and dataset inventory of the HF Market Data API: every asset type, timeframe and adjustment with instrument counts.') |
| 16 | 23 | const [health, setHealth] = useState({ state: 'loading' }) |
| 17 | 24 | const [status, setStatus] = useState({ state: 'loading' }) |
| 18 | 25 | const [openapi, setOpenapi] = useState({ state: 'loading' }) |
| 19 | − useEffect(() => { | |
| 26 | + const [checkedAt, setCheckedAt] = useState(null) | |
| 27 | + | |
| 28 | + const check = useCallback(() => { | |
| 20 | 29 | let alive = true |
| 21 | − api('/health').then(({ data, ms }) => alive && setHealth({ state: 'ok', data, ms }), e => alive && setHealth({ state: 'down', error: e })) | |
| 22 | − api('/v1/status').then(({ data, ms }) => alive && setStatus({ state: 'ok', data, ms }), e => alive && setStatus({ state: 'down', error: e })) | |
| 23 | − fetch('/openapi.json', { headers: { Accept: 'application/json' } }).then(r => alive && setOpenapi({ state: r.ok && (r.headers.get('content-type') || '').includes('json') ? 'ok' : 'down' }), () => alive && setOpenapi({ state: 'down' })) | |
| 30 | + setHealth({ state: 'loading' }); setStatus({ state: 'loading' }); setOpenapi({ state: 'loading' }) | |
| 31 | + api('/health').then(({ data, ms }) => alive && setHealth({ state: 'ok', data, ms }), e => alive && setHealth({ state: classify(e), error: e })) | |
| 32 | + api('/v1/status').then(({ data, ms }) => alive && setStatus({ state: 'ok', data, ms }), e => alive && setStatus({ state: classify(e), error: e })) | |
| 33 | + fetch(`${BASE_URL}/openapi.json`, { headers: { Accept: 'application/json' } }) | |
| 34 | + .then(r => alive && setOpenapi({ state: r.ok && (r.headers.get('content-type') || '').includes('json') ? 'ok' : r.status === 429 ? 'limited' : 'down' }), () => alive && setOpenapi({ state: 'down' })) | |
| 35 | + setCheckedAt(new Date()) | |
| 24 | 36 | return () => { alive = false } |
| 25 | 37 | }, []) |
| 38 | + useEffect(() => check(), [check]) | |
| 39 | + | |
| 26 | 40 | const ds = status.data?.datasets || {} |
| 27 | 41 | const assets = Object.keys(ds).filter(k => k !== 'options') |
| 28 | − const allUp = health.state === 'ok' && status.state === 'ok' | |
| 42 | + const states = [health.state, status.state, openapi.state] | |
| 43 | + const overall = states.includes('loading') ? 'loading' : states.includes('down') ? 'down' : states.includes('limited') ? 'limited' : 'ok' | |
| 44 | + const lakeState = health.state === 'ok' ? (health.data?.data_root_present === false ? 'down' : 'ok') : health.state | |
| 45 | + | |
| 29 | 46 | return ( |
| 30 | 47 | <main className="page status" data-testid="status-page"> |
| 31 | 48 | <header className="status-head"> |
| 32 | 49 | <p className="eyebrow">Status</p> |
| 33 | 50 | <h1>API health & dataset inventory</h1> |
| 34 | − <p className="lead">Everything on this page is fetched live from the API when you open it — nothing is cached or hand-maintained.</p> | |
| 51 | + <p className="lead">Everything on this page is fetched live from the API when you open it — nothing is cached or hand-maintained. <code>/v1/status</code> and <code>/health</code> do not count against your quota.</p> | |
| 52 | + <div className="status-overall" data-state={overall} data-testid="status-overall"> | |
| 53 | + <span className="status-dot" aria-hidden="true" /> | |
| 54 | + <strong>{overall === 'ok' ? 'All systems operational' : overall === 'limited' ? 'Operational — your IP is rate limited' : overall === 'down' ? 'Some checks failed' : 'Checking…'}</strong> | |
| 55 | + {checkedAt && <span className="muted small">checked {checkedAt.toISOString().replace('T', ' ').slice(0, 19)} UTC</span>} | |
| 56 | + <button type="button" className="btn btn-sm" onClick={check} disabled={overall === 'loading'}>Re-check</button> | |
| 57 | + </div> | |
| 35 | 58 | </header> |
| 36 | 59 | |
| 37 | 60 | <section className="health-grid" aria-label="Service health"> |
| 38 | − <HealthCard label="API" state={health.state} detail={health.state === 'ok' ? `${health.data?.service || 'hfmarketdata-api'} v${health.data?.version || '?'} · ${health.ms} ms` : health.state === 'down' ? 'Unreachable' : 'Checking…'} /> | |
| 39 | − <HealthCard label="Data lake" state={health.state === 'ok' ? (health.data?.data_root_present === false ? 'down' : 'ok') : health.state} detail={health.state === 'ok' ? (health.data?.data_root_present === false ? 'Not mounted' : 'Mounted') : health.state === 'down' ? 'Unknown' : 'Checking…'} /> | |
| 40 | − <HealthCard label="Inventory (/v1/status)" state={status.state} detail={status.state === 'ok' ? `${assets.length} asset types · ${status.ms} ms` : status.state === 'down' ? 'Unreachable' : 'Checking…'} /> | |
| 41 | − <HealthCard label="OpenAPI spec" state={openapi.state} detail={openapi.state === 'ok' ? 'Served at /openapi.json' : openapi.state === 'down' ? 'Not served (docs use the bundled snapshot)' : 'Checking…'} /> | |
| 61 | + <HealthCard label="API" state={health.state} detail={health.state === 'ok' ? `${health.data?.service || 'hfmarketdata-api'} v${health.data?.version || '?'} · ${health.ms} ms` : describe(health)} /> | |
| 62 | + <HealthCard label="Data lake" state={lakeState} detail={health.state === 'ok' ? (health.data?.data_root_present === false ? 'Not mounted' : 'Mounted') : describe(health)} /> | |
| 63 | + <HealthCard label="Inventory" state={status.state} detail={status.state === 'ok' ? `${assets.length} asset types · ${status.ms} ms` : describe(status)} /> | |
| 64 | + <HealthCard label="OpenAPI spec" state={openapi.state} detail={openapi.state === 'ok' ? 'Served at /openapi.json' : openapi.state === 'limited' ? 'Rate limited' : openapi.state === 'down' ? 'Not served (docs use the bundled snapshot)' : 'Checking…'} /> | |
| 42 | 65 | </section> |
| 43 | 66 | |
| 44 | − {status.state === 'down' && <Callout type="warning" title="The API did not answer">This page could not reach <code>{PUBLIC_BASE}/v1/status</code>. If the problem persists, e-mail <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</Callout>} | |
| 67 | + {status.state === 'limited' && ( | |
| 68 | + <Callout type="warning" title="Rate limited, not down" data-testid="status-limited"> | |
| 69 | + The API answered <code>429</code>: the keyless quota of your IP address is used up (30 requests / hour, shared behind a corporate NAT). The service itself is up. Retry after the window resets, or <Link to="/signup">create a free account</Link> for 120 requests / minute. | |
| 70 | + </Callout> | |
| 71 | + )} | |
| 72 | + {status.state === 'down' && <Callout type="danger" title="The API did not answer">This page could not reach <code>{PUBLIC_BASE}/v1/status</code>. If the problem persists, e-mail <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</Callout>} | |
| 45 | 73 | |
| 46 | 74 | <section aria-labelledby="inv-h"> |
| 47 | 75 | <h2 id="inv-h">Datasets</h2> |
| 48 | 76 | <p className="muted">Number of instruments available per asset type, timeframe and adjustment variant. Intraday timeframes for stocks and ETFs exist in split- and split+dividend-adjusted variants; unadjusted files exist for 1-minute and daily.</p> |
| 49 | − {status.state === 'loading' && <p className="muted" aria-busy="true">Loading inventory…</p>} | |
| 77 | + {status.state === 'loading' && <div className="inv-skeleton" aria-busy="true"><Skeleton block height={18} /><Skeleton block height={120} /></div>} | |
| 50 | 78 | {assets.map(asset => { |
| 51 | 79 | const tfs = ds[asset] |
| 52 | − const adjustments = [...new Set(Object.values(tfs).flatMap(o => Object.keys(o)))] | |
| 80 | + const adjustments = [...new Set(Object.values(tfs).flatMap(o => Object.keys(o).filter(k => typeof o[k] === 'number')))] | |
| 81 | + const columns = [{ key: 'adj', label: 'Adjustment', render: r => <code>{r.adj}</code> }, ...TIMEFRAMES.map(tf => ({ key: tf, label: tf, align: 'right', render: r => (r[tf] != null ? fmt(r[tf]) : <span className="muted">—</span>) }))] | |
| 82 | + const rows = adjustments.map(adj => ({ adj, ...Object.fromEntries(TIMEFRAMES.map(tf => [tf, tfs[tf]?.[adj]])) })) | |
| 53 | 83 | return ( |
| 54 | 84 | <div key={asset} className="inv-block" data-testid={`inv-${asset}`}> |
| 55 | 85 | <h3>{ASSET_LABEL[asset] || asset} <code className="muted">{asset}</code></h3> |
| 56 | − <div className="table-wrap"> | |
| 57 | − <table> | |
| 58 | − <thead><tr><th>Adjustment</th>{TIMEFRAMES.map(tf => <th key={tf} className="num">{tf}</th>)}</tr></thead> | |
| 59 | − <tbody> | |
| 60 | − {adjustments.map(adj => ( | |
| 61 | − <tr key={adj}> | |
| 62 | − <td><code>{adj}</code></td> | |
| 63 | − {TIMEFRAMES.map(tf => <td key={tf} className="num">{tfs[tf]?.[adj] != null ? fmt(tfs[tf][adj]) : <span className="muted">—</span>}</td>)} | |
| 64 | − </tr> | |
| 65 | − ))} | |
| 66 | − </tbody> | |
| 67 | − </table> | |
| 68 | − </div> | |
| 86 | + <Table columns={columns} rows={rows} keyField="adj" dense stickyFirst caption={`${ASSET_LABEL[asset] || asset}: instruments per adjustment and timeframe`} /> | |
| 69 | 87 | <LastDates data={tfs} /> |
| 70 | 88 | </div> |
| 71 | 89 | ) |
@@ -74,13 +92,13 @@ export default function Status() { | ||
| 74 | 92 | <div className="inv-block" data-testid="inv-options"> |
| 75 | 93 | <h3>Options <code className="muted">options</code></h3> |
| 76 | 94 | <div className="opt-facts"> |
| 77 | − <div><span className="n">{fmt(ds.options.quarters?.length)}</span><span>quarterly archives</span></div> | |
| 78 | − <div><span className="n">{ds.options.quarters?.[0]} → {ds.options.quarters?.at(-1)}</span><span>coverage</span></div> | |
| 79 | − {ds.options.files_latest_quarter != null && <div><span className="n">{fmt(ds.options.files_latest_quarter)}</span><span>underlyings in the latest quarter</span></div>} | |
| 95 | + <Stat label="Quarterly archives" value={fmt(ds.options.quarters?.length)} /> | |
| 96 | + <Stat label="Coverage" value={<>{ds.options.quarters?.[0]} → {ds.options.quarters?.at(-1)}</>} /> | |
| 97 | + {ds.options.files_latest_quarter != null && <Stat label="Underlyings, latest quarter" value={fmt(ds.options.files_latest_quarter)} />} | |
| 80 | 98 | </div> |
| 99 | + <LastDates data={ds.options} /> | |
| 81 | 100 | </div> |
| 82 | 101 | )} |
| 83 | − {Object.keys(ds).filter(k => !assets.includes(k) && k !== 'options').length > 0 && <pre>{JSON.stringify(Object.fromEntries(Object.entries(ds).filter(([k]) => !assets.includes(k) && k !== 'options')), null, 2)}</pre>} | |
| 84 | 102 | </section> |
| 85 | 103 | |
| 86 | 104 | <section className="status-notes"> |
@@ -91,17 +109,23 @@ export default function Status() { | ||
| 91 | 109 | <li><strong>Limits.</strong> Your own remaining quota is in the <code>X-RateLimit-*</code> headers of every response and at <code>GET /v1/limits</code>. See <Link to="/limits">access & limits</Link>.</li> |
| 92 | 110 | <li><strong>Contact.</strong> <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</li> |
| 93 | 111 | </ul> |
| 94 | − {allUp && <p className="muted small">All checks passed at {new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC.</p>} | |
| 95 | 112 | </section> |
| 96 | 113 | </main> |
| 97 | 114 | ) |
| 98 | 115 | } |
| 99 | 116 | |
| 117 | +function describe(x) { | |
| 118 | + if (x.state === 'limited') return 'Rate limited (429) — the service is up' | |
| 119 | + if (x.state === 'down') return 'Unreachable' | |
| 120 | + return 'Checking…' | |
| 121 | +} | |
| 122 | + | |
| 100 | 123 | function HealthCard({ label, state, detail }) { |
| 101 | − const tone = state === 'ok' ? 'success' : state === 'down' ? 'danger' : 'neutral' | |
| 124 | + const tone = state === 'ok' ? 'success' : state === 'down' ? 'danger' : state === 'limited' ? 'warn' : 'neutral' | |
| 125 | + const text = state === 'ok' ? 'Operational' : state === 'down' ? 'Down' : state === 'limited' ? 'Rate limited' : '…' | |
| 102 | 126 | return ( |
| 103 | 127 | <div className="health-card" data-state={state}> |
| 104 | − <div className="health-top"><span>{label}</span><Badge tone={tone}>{state === 'ok' ? 'Operational' : state === 'down' ? 'Down' : '…'}</Badge></div> | |
| 128 | + <div className="health-top"><span>{label}</span><Badge tone={tone} dot={state !== 'loading'}>{text}</Badge></div> | |
| 105 | 129 | <div className="muted small">{detail}</div> |
| 106 | 130 | </div> |
| 107 | 131 | ) |
@@ -109,7 +133,11 @@ function HealthCard({ label, state, detail }) { | ||
| 109 | 133 | |
| 110 | 134 | // /v1/status may grow `last_date` / `first_date` style fields per timeframe — render them when present, never invent. |
| 111 | 135 | function LastDates({ data }) { |
| 112 | − const rows = Object.entries(data).flatMap(([tf, o]) => Object.entries(o).filter(([k, v]) => /date|updated|as_of/i.test(k) && typeof v === 'string').map(([k, v]) => `${tf} ${k}: ${v}`)) | |
| 136 | + const rows = [] | |
| 137 | + for (const [k, v] of Object.entries(data || {})) { | |
| 138 | + if (typeof v === 'string' && /date|updated|as_of/i.test(k)) rows.push(`${k}: ${v}`) | |
| 139 | + else if (v && typeof v === 'object' && !Array.isArray(v)) for (const [k2, v2] of Object.entries(v)) if (typeof v2 === 'string' && /date|updated|as_of/i.test(k2)) rows.push(`${k} ${k2}: ${v2}`) | |
| 140 | + } | |
| 113 | 141 | if (!rows.length) return null |
| 114 | − return <p className="muted small">{rows.join(' · ')}</p> | |
| 142 | + return <p className="muted small inv-dates" data-testid="last-dates">Last data: {rows.join(' · ')}</p> | |
| 115 | 143 | } |
modified
hfmarketdata/web/src/pages/status/status.css
+20 −8
@@ -1,17 +1,29 @@ | ||
| 1 | 1 | .status-head { max-width: 760px; margin-bottom: 28px; } |
| 2 | +.status-overall { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg-1); } | |
| 3 | +.status-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--fg-3); flex: none; } | |
| 4 | +.status-overall[data-state="ok"] .status-dot { background: var(--success); box-shadow: 0 0 0 4px var(--success-soft); } | |
| 5 | +.status-overall[data-state="limited"] .status-dot { background: var(--warn); box-shadow: 0 0 0 4px var(--warn-soft); } | |
| 6 | +.status-overall[data-state="down"] .status-dot { background: var(--danger); box-shadow: 0 0 0 4px var(--danger-soft); } | |
| 7 | +.status-overall .btn { margin-left: auto; } | |
| 2 | 8 | .health-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 36px; } |
| 3 | −.health-card { padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--bg-1); border-left-width: 3px; } | |
| 4 | −.health-card[data-state="ok"] { border-left-color: var(--accent); } | |
| 9 | +.health-card { padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); border-left-width: 3px; min-width: 0; } | |
| 10 | +.health-card[data-state="ok"] { border-left-color: var(--success); } | |
| 11 | +.health-card[data-state="limited"] { border-left-color: var(--warn); } | |
| 5 | 12 | .health-card[data-state="down"] { border-left-color: var(--danger); } |
| 6 | 13 | .health-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-weight: 600; margin-bottom: 6px; } |
| 7 | 14 | .inv-block { margin: 26px 0; } |
| 8 | −.inv-block h3 { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; } | |
| 9 | −.inv-block h3 code { font-size: 12px; } | |
| 15 | +.inv-block h3 { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; font-size: var(--fs-4); } | |
| 16 | +.inv-block h3 code { font-size: var(--fs-0); } | |
| 17 | +.inv-block .table-wrap code { font-size: var(--fs-0); white-space: nowrap; } | |
| 18 | +.inv-dates { margin: 8px 0 0; } | |
| 19 | +.inv-skeleton { display: grid; gap: 10px; margin: 20px 0; } | |
| 10 | 20 | .opt-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } |
| 11 | −.opt-facts > div { padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); display: grid; gap: 2px; font-size: 13px; color: var(--fg-2); } | |
| 12 | −.opt-facts .n { font-size: 22px; font-weight: 700; color: var(--fg); font-variant-numeric: tabular-nums; } | |
| 13 | 21 | .status-notes { margin-top: 40px; max-width: 820px; } |
| 14 | 22 | .status-notes ul { padding-left: 1.2em; color: var(--fg-1); } |
| 15 | 23 | .status-notes li + li { margin-top: 8px; } |
| 16 | −@media (max-width: 900px) { .health-grid { grid-template-columns: 1fr 1fr; } .opt-facts { grid-template-columns: 1fr; } } | |
| 17 | −@media (max-width: 560px) { .health-grid { grid-template-columns: 1fr; } } | |
| 24 | +@media (max-width: 960px) { .health-grid { grid-template-columns: 1fr 1fr; } .opt-facts { grid-template-columns: 1fr; } } | |
| 25 | +@media (max-width: 640px) { | |
| 26 | + .health-grid { grid-template-columns: 1fr; } | |
| 27 | + .status-overall .btn { margin-left: 0; width: 100%; } | |
| 28 | + .inv-block .table-wrap th, .inv-block .table-wrap td { padding: 6px 8px; font-size: var(--fs-0); } | |
| 29 | +} | |
added
hfmarketdata/web/src/playground/EndpointPicker.jsx
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +// Endpoint selector — a command-palette style combobox: trigger shows METHOD · title · path; the popover has a search | |
| 2 | +// box and a listbox grouped by catalog group. Keyboard: ↑ ↓ Enter Esc; typing filters on title, path and group. | |
| 3 | +import React, { useEffect, useId, useMemo, useRef, useState } from 'react' | |
| 4 | +import { ChevronDownIcon, SearchIcon } from '../components/Icons.jsx' | |
| 5 | +import { CATALOG, GROUPS } from './catalog.js' | |
| 6 | + | |
| 7 | +export default function EndpointPicker({ entry, onSelect, extra = [] }) { | |
| 8 | + const [open, setOpen] = useState(false) | |
| 9 | + const [q, setQ] = useState('') | |
| 10 | + const [active, setActive] = useState(0) | |
| 11 | + const wrapRef = useRef(null) | |
| 12 | + const inputRef = useRef(null) | |
| 13 | + const listRef = useRef(null) | |
| 14 | + const id = useId() | |
| 15 | + | |
| 16 | + const all = useMemo(() => [...CATALOG, ...extra.filter(e => !CATALOG.some(c => c.id === e.id))], [extra]) | |
| 17 | + const filtered = useMemo(() => { | |
| 18 | + const t = q.trim().toLowerCase() | |
| 19 | + if (!t) return all | |
| 20 | + return all.filter(e => `${e.title} ${e.path} ${e.group} ${e.method}`.toLowerCase().includes(t)) | |
| 21 | + }, [all, q]) | |
| 22 | + const groups = useMemo(() => { | |
| 23 | + const names = [...GROUPS, ...filtered.map(e => e.group).filter(g => !GROUPS.includes(g))] | |
| 24 | + return [...new Set(names)].map(g => ({ name: g, items: filtered.filter(e => e.group === g) })).filter(g => g.items.length) | |
| 25 | + }, [filtered]) | |
| 26 | + const flat = useMemo(() => groups.flatMap(g => g.items), [groups]) | |
| 27 | + | |
| 28 | + useEffect(() => { if (!open) return undefined; setQ(''); setActive(Math.max(0, all.findIndex(e => e.id === entry.id))); const t = setTimeout(() => inputRef.current?.focus(), 10); return () => clearTimeout(t) }, [open]) // eslint-disable-line react-hooks/exhaustive-deps | |
| 29 | + useEffect(() => { setActive(0) }, [q]) | |
| 30 | + useEffect(() => { | |
| 31 | + if (!open) return undefined | |
| 32 | + const onDoc = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false) } | |
| 33 | + document.addEventListener('mousedown', onDoc) | |
| 34 | + return () => document.removeEventListener('mousedown', onDoc) | |
| 35 | + }, [open]) | |
| 36 | + useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }) }, [active, open]) | |
| 37 | + | |
| 38 | + const pick = e => { if (!e) return; onSelect(e); setOpen(false) } | |
| 39 | + const onKey = e => { | |
| 40 | + if (e.key === 'ArrowDown') { e.preventDefault(); setActive(a => Math.min(flat.length - 1, a + 1)) } | |
| 41 | + else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(a => Math.max(0, a - 1)) } | |
| 42 | + else if (e.key === 'Enter') { e.preventDefault(); pick(flat[active]) } | |
| 43 | + else if (e.key === 'Escape') { e.preventDefault(); setOpen(false) } | |
| 44 | + else if (e.key === 'Tab') setOpen(false) | |
| 45 | + } | |
| 46 | + let idx = -1 | |
| 47 | + return ( | |
| 48 | + <div className="pg-ep" ref={wrapRef}> | |
| 49 | + <button type="button" className="pg-ep-trigger" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} data-testid="pg-endpoint" aria-label={`Endpoint: ${entry.title}. Change endpoint`}> | |
| 50 | + <span className={`pg-method ${entry.method.toLowerCase()}`}>{entry.method}</span> | |
| 51 | + <span className="pg-ep-text"><span className="pg-ep-title">{entry.title}</span><span className="pg-ep-path mono">{entry.path}</span></span> | |
| 52 | + <ChevronDownIcon /> | |
| 53 | + </button> | |
| 54 | + {open && ( | |
| 55 | + <div className="pg-ep-pop" role="presentation"> | |
| 56 | + <div className="pg-ep-search"> | |
| 57 | + <SearchIcon /> | |
| 58 | + <input ref={inputRef} type="text" value={q} onChange={e => setQ(e.target.value)} onKeyDown={onKey} placeholder="Search endpoints… (title, path, group)" role="combobox" aria-expanded="true" aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={flat[active] ? `${id}-opt-${flat[active].id}` : undefined} aria-label="Search endpoints" autoComplete="off" spellCheck={false} data-testid="pg-endpoint-search" /> | |
| 59 | + <kbd>esc</kbd> | |
| 60 | + </div> | |
| 61 | + <div className="pg-ep-list" id={`${id}-list`} role="listbox" aria-label="Endpoints" ref={listRef}> | |
| 62 | + {flat.length === 0 && <div className="pg-ep-empty muted">No endpoint matches “{q}”.</div>} | |
| 63 | + {groups.map(g => ( | |
| 64 | + <div key={g.name} role="group" aria-label={g.name}> | |
| 65 | + <div className="pg-ep-group">{g.name}</div> | |
| 66 | + {g.items.map(e => { | |
| 67 | + idx += 1 | |
| 68 | + const i = idx | |
| 69 | + return ( | |
| 70 | + <div key={e.id} id={`${id}-opt-${e.id}`} role="option" aria-selected={i === active} className={`pg-ep-opt ${e.id === entry.id ? 'current' : ''}`} data-ep={e.id} | |
| 71 | + onMouseEnter={() => setActive(i)} onMouseDown={ev => ev.preventDefault()} onClick={() => pick(e)}> | |
| 72 | + <span className={`pg-method ${e.method.toLowerCase()}`}>{e.method}</span> | |
| 73 | + <span className="pg-ep-text"><span className="pg-ep-title">{e.title}</span><span className="pg-ep-path mono">{e.path}</span></span> | |
| 74 | + {e.id === entry.id && <span className="pg-ep-current muted">current</span>} | |
| 75 | + </div> | |
| 76 | + ) | |
| 77 | + })} | |
| 78 | + </div> | |
| 79 | + ))} | |
| 80 | + </div> | |
| 81 | + <div className="pg-ep-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> navigate</span><span><kbd>↵</kbd> select</span><span>{all.length} endpoints</span></div> | |
| 82 | + </div> | |
| 83 | + )} | |
| 84 | + </div> | |
| 85 | + ) | |
| 86 | +} | |
modified
hfmarketdata/web/src/playground/Playground.jsx
+181 −143
@@ -1,15 +1,22 @@ | ||
| 1 | 1 | // Interactive playground — reusable: <Playground apiKey={sessionKey} embedded /> (dashboard) or bare (/playground). |
| 2 | −// Deep links: /playground?ep=<catalogId|operationId>&<param>=<value>… (see PLAYGROUND.md). | |
| 2 | +// Layout: endpoint picker (combobox) + presets · parameters grid · URL + Run · full-width results (JSON / Table / | |
| 3 | +// Chart / Code / Download) · rate-limit panel. Empty state = the captured production example of the selected | |
| 4 | +// endpoint ("Edit a parameter and run"). A valid deep link (?ep=…¶m=…) runs once automatically. | |
| 5 | +// Deep links: /playground?ep=<catalogId|operationId>&<param>=<value>… (see README). | |
| 3 | 6 | import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| 4 | 7 | import { Link, useLocation, useSearchParams } from 'react-router-dom' |
| 5 | 8 | import { BASE_URL, PUBLIC_BASE, rateHeaders } from '../app/api.js' |
| 6 | 9 | import { useAuth } from '../app/auth.jsx' |
| 7 | −import PgCallout from '../components/PgCallout.jsx' | |
| 8 | −import PgCodeBlock from '../components/PgCodeBlock.jsx' | |
| 9 | −import PgCopyButton from '../components/PgCopyButton.jsx' | |
| 10 | −import PgTabs from '../components/PgTabs.jsx' | |
| 11 | −import { CATALOG, GROUPS, buildPath, byId, defaultValues, entryFromSpec, findByPath, findEntry, specOperation, validate } from './catalog.js' | |
| 10 | +import Callout from '../components/Callout.jsx' | |
| 11 | +import CodeTabs from '../components/CodeTabs.jsx' | |
| 12 | +import CopyButton from '../components/CopyButton.jsx' | |
| 13 | +import { PlayIcon } from '../components/Icons.jsx' | |
| 14 | +import { EmptyState, ErrorState } from '../components/States.jsx' | |
| 15 | +import Tabs from '../components/Tabs.jsx' | |
| 16 | +import { loadExampleFile } from '../docs/enrich.js' | |
| 17 | +import { CATALOG, buildPath, byId, defaultValues, entryFromSpec, findByPath, findEntry, normalizeValues, specOperation, validate } from './catalog.js' | |
| 12 | 18 | import { curlSnippet, jsSnippet, pythonSnippet } from './codegen.js' |
| 19 | +import EndpointPicker from './EndpointPicker.jsx' | |
| 13 | 20 | import { PRESETS } from './presets.js' |
| 14 | 21 | import RateLimitPanel from './RateLimitPanel.jsx' |
| 15 | 22 | import RequestForm from './RequestForm.jsx' |
@@ -60,7 +67,7 @@ function parseCsv(text, max = 50_000) { | ||
| 60 | 67 | return rows |
| 61 | 68 | } |
| 62 | 69 | |
| 63 | −function extractRows(json, entry) { | |
| 70 | +export function extractRows(json, entry) { | |
| 64 | 71 | if (!json || typeof json !== 'object') return [] |
| 65 | 72 | if (Array.isArray(json)) return json |
| 66 | 73 | if (Array.isArray(json.data)) return json.data |
@@ -74,28 +81,37 @@ function extractRows(json, entry) { | ||
| 74 | 81 | |
| 75 | 82 | const isRouteMissing = (status, body) => status === 404 && (body == null || typeof body === 'string' || (body.detail === 'Not Found' && !body.error) || body?.error?.code === 'NOT_FOUND' && /route|endpoint|not found$/i.test(body?.error?.message || '')) |
| 76 | 83 | |
| 84 | +const pickView = (out, entry) => { | |
| 85 | + const hasRows = out.rows?.length > 0 | |
| 86 | + const kind = entry.result?.kind | |
| 87 | + if (out.blob && out.format !== 'csv') return 'download' | |
| 88 | + if (hasRows && (kind === 'bars' || kind === 'series') && detectTimeKey(out.rows, entry.result?.timeKey)) return 'chart' | |
| 89 | + if (hasRows) return 'table' | |
| 90 | + return 'json' | |
| 91 | +} | |
| 92 | + | |
| 77 | 93 | export default function Playground({ apiKey, embedded = false }) { |
| 78 | 94 | const { user } = useAuth() |
| 79 | 95 | const location = useLocation() |
| 80 | − const [searchParams, setSearchParams] = useSearchParams() | |
| 96 | + const [, setSearchParams] = useSearchParams() | |
| 81 | 97 | const [entry, setEntry] = useState(() => byId(DEFAULT_EP)) |
| 82 | 98 | const [values, setValues] = useState(() => defaultValues(byId(DEFAULT_EP))) |
| 83 | 99 | const [notice, setNotice] = useState(null) |
| 84 | 100 | const [result, setResult] = useState(null) |
| 101 | + const [sample, setSample] = useState(null) // captured production example of the current entry (empty state) | |
| 85 | 102 | const [running, setRunning] = useState(false) |
| 86 | 103 | const [view, setView] = useState('table') |
| 87 | − const [codeLang, setCodeLang] = useState('curl') | |
| 88 | 104 | const [retryUntil, setRetryUntil] = useState(null) |
| 89 | 105 | const [lastRate, setLastRate] = useState(null) |
| 90 | − const [filter, setFilter] = useState('') | |
| 106 | + const [autoRun, setAutoRun] = useState(false) | |
| 91 | 107 | const lastWritten = useRef(null) |
| 92 | 108 | const ready = useRef(false) // URL → state initialisation done; only then do we write state → URL |
| 109 | + const autoRan = useRef(false) | |
| 93 | 110 | const abortRef = useRef(null) |
| 94 | − const resultRef = useRef(null) | |
| 95 | 111 | |
| 96 | 112 | // ---- deep links: (re)initialise from the URL whenever it changes externally |
| 97 | 113 | useEffect(() => { |
| 98 | − if (location.search === lastWritten.current) return | |
| 114 | + if (location.search === lastWritten.current) return undefined | |
| 99 | 115 | const sp = new URLSearchParams(location.search) |
| 100 | 116 | const ref = sp.get('ep') |
| 101 | 117 | const urlValues = {} |
@@ -104,12 +120,13 @@ export default function Playground({ apiKey, embedded = false }) { | ||
| 104 | 120 | setEntry(e) |
| 105 | 121 | const dv = defaultValues(e) |
| 106 | 122 | for (const [k, v] of Object.entries(urlValues)) if (k in dv || e.fromSpec) dv[k] = v |
| 107 | − setValues(dv) | |
| 123 | + setValues(normalizeValues(e, dv)) | |
| 108 | 124 | setResult(null) |
| 109 | 125 | if (unknown) setNotice({ kind: 'warn', text: `Unknown request type "${unknown}" — showing ${e.title} instead.` }) |
| 110 | 126 | else if (generic) setNotice({ kind: 'info', text: `${e.title}: form generated from the OpenAPI spec (no curated preset yet).` }) |
| 111 | 127 | else setNotice(null) |
| 112 | 128 | ready.current = true |
| 129 | + if (ref && !unknown && !autoRan.current) { autoRan.current = true; setAutoRun(true) } | |
| 113 | 130 | } |
| 114 | 131 | // synchronous fast path (catalog id / known operationId) avoids a flash of the default form |
| 115 | 132 | const direct = ref ? findEntry(ref, urlValues) : byId(DEFAULT_EP) |
@@ -131,6 +148,22 @@ export default function Playground({ apiKey, embedded = false }) { | ||
| 131 | 148 | else lastWritten.current = next |
| 132 | 149 | }, [entry, values]) // eslint-disable-line react-hooks/exhaustive-deps |
| 133 | 150 | |
| 151 | + // ---- captured production example for the empty state | |
| 152 | + useEffect(() => { | |
| 153 | + let alive = true | |
| 154 | + setSample(null) | |
| 155 | + const opId = entry.operationIds?.[0] | |
| 156 | + if (!opId) return undefined | |
| 157 | + loadExampleFile(opId).then(file => { | |
| 158 | + if (!alive || !file?.body) return | |
| 159 | + const json = file.body | |
| 160 | + const rows = extractRows(json, entry) | |
| 161 | + const out = { status: file.status || 200, ok: true, json, rows, rowCount: json?.count ?? rows.length, format: 'json', captured: true, capturedAt: file.captured_at, url: file.url } | |
| 162 | + setSample(out) | |
| 163 | + }) | |
| 164 | + return () => { alive = false } | |
| 165 | + }, [entry]) | |
| 166 | + | |
| 134 | 167 | const errors = useMemo(() => validate(entry, values), [entry, values]) |
| 135 | 168 | const relUrl = useMemo(() => buildPath(entry, values), [entry, values]) |
| 136 | 169 | const fullUrl = PUBLIC_BASE + relUrl |
@@ -138,26 +171,27 @@ export default function Playground({ apiKey, embedded = false }) { | ||
| 138 | 171 | const isWs = entry.result?.kind === 'ws' |
| 139 | 172 | const tier = user?.tier || (apiKey ? 'free' : 'keyless') |
| 140 | 173 | const authenticated = !!apiKey |
| 174 | + const errorCount = Object.keys(errors).length | |
| 141 | 175 | |
| 142 | 176 | const selectEntry = useCallback((e, preset) => { |
| 143 | 177 | abortRef.current?.abort() |
| 144 | 178 | setEntry(e) |
| 145 | − setValues({ ...defaultValues(e), ...(preset || {}) }) | |
| 179 | + setValues(normalizeValues(e, { ...defaultValues(e), ...(preset || {}) })) | |
| 146 | 180 | setResult(null) |
| 147 | 181 | setNotice(null) |
| 148 | 182 | }, []) |
| 149 | 183 | |
| 150 | 184 | const onChange = useCallback((name, v) => setValues(vals => { |
| 151 | 185 | const next = { ...vals, [name]: v } |
| 152 | − // reset dependent enums (adjustment) when asset changes | |
| 186 | + // dependent enums (adjustment) follow the asset | |
| 153 | 187 | if (name === 'asset') for (const prm of entry.params) if (prm.enumBy?.asset) next[prm.name] = '' |
| 154 | − return next | |
| 188 | + return normalizeValues(entry, next) | |
| 155 | 189 | }), [entry]) |
| 156 | 190 | |
| 157 | 191 | const applyPreset = ps => { const e = byId(ps.ep); if (e) selectEntry(e, ps.values()) } |
| 158 | 192 | |
| 159 | 193 | const run = useCallback(async () => { |
| 160 | − if (Object.keys(errors).length || isWs) return | |
| 194 | + if (errorCount || isWs) return | |
| 161 | 195 | abortRef.current?.abort() |
| 162 | 196 | const ctrl = new AbortController() |
| 163 | 197 | abortRef.current = ctrl |
@@ -175,7 +209,8 @@ export default function Playground({ apiKey, embedded = false }) { | ||
| 175 | 209 | const out = { status: res.status, ok: res.ok, ms, rate, format, statusText: res.statusText } |
| 176 | 210 | if (res.status === 429) { |
| 177 | 211 | const wait = rate.retryAfter != null ? rate.retryAfter : rate.reset ? rate.reset - Math.floor(Date.now() / 1000) : 60 |
| 178 | − setRetryUntil(Math.floor(Date.now() / 1000) + Math.max(1, wait)) | |
| 212 | + out.retryUntil = Math.floor(Date.now() / 1000) + Math.max(1, wait) | |
| 213 | + setRetryUntil(out.retryUntil) | |
| 179 | 214 | } else setRetryUntil(null) |
| 180 | 215 | if (ct.includes('json')) { |
| 181 | 216 | out.json = await res.json() |
@@ -202,12 +237,7 @@ export default function Playground({ apiKey, embedded = false }) { | ||
| 202 | 237 | } |
| 203 | 238 | if (!res.ok && isRouteMissing(res.status, out.json ?? out.text)) out.comingSoon = true |
| 204 | 239 | setResult(out) |
| 205 | − const hasRows = out.rows?.length > 0 | |
| 206 | − const kind = entry.result?.kind | |
| 207 | − if (out.blob && format !== 'csv') setView('download') | |
| 208 | − else if (hasRows && (kind === 'bars' || kind === 'series') && detectTimeKey(out.rows, entry.result?.timeKey)) setView('chart') | |
| 209 | − else if (hasRows) setView('table') | |
| 210 | − else setView('json') | |
| 240 | + setView(pickView(out, entry)) | |
| 211 | 241 | } catch (e) { |
| 212 | 242 | if (e.name === 'AbortError') return |
| 213 | 243 | setResult({ status: 0, ok: false, ms: Math.round(performance.now() - t0), error: { code: 'NETWORK_ERROR', message: e.message || 'Network error' } }) |
@@ -215,7 +245,14 @@ export default function Playground({ apiKey, embedded = false }) { | ||
| 215 | 245 | } finally { |
| 216 | 246 | if (!ctrl.signal.aborted) setRunning(false) |
| 217 | 247 | } |
| 218 | − }, [errors, isWs, format, apiKey, relUrl, entry]) | |
| 248 | + }, [errorCount, isWs, format, apiKey, relUrl, entry]) | |
| 249 | + | |
| 250 | + // auto-run once on a valid deep link | |
| 251 | + useEffect(() => { | |
| 252 | + if (!autoRun) return | |
| 253 | + setAutoRun(false) | |
| 254 | + if (!errorCount && !isWs) run() | |
| 255 | + }, [autoRun, errorCount, isWs, run]) | |
| 219 | 256 | |
| 220 | 257 | // Ctrl/Cmd+Enter runs |
| 221 | 258 | useEffect(() => { |
@@ -226,153 +263,154 @@ export default function Playground({ apiKey, embedded = false }) { | ||
| 226 | 263 | |
| 227 | 264 | const downloadUrl = useMemo(() => (result?.blob ? URL.createObjectURL(result.blob) : null), [result]) |
| 228 | 265 | useEffect(() => () => { if (downloadUrl) URL.revokeObjectURL(downloadUrl) }, [downloadUrl]) |
| 266 | + const jsonDownload = useMemo(() => (result?.json && result.ok ? URL.createObjectURL(new Blob([JSON.stringify(result.json)], { type: 'application/json' })) : null), [result]) | |
| 267 | + useEffect(() => () => { if (jsonDownload) URL.revokeObjectURL(jsonDownload) }, [jsonDownload]) | |
| 229 | 268 | |
| 230 | − const code = useMemo(() => { | |
| 269 | + const codeSnippets = useMemo(() => { | |
| 231 | 270 | const opts = { hasKey: authenticated, format, envelope: entry.result?.envelope } |
| 232 | − return { curl: curlSnippet(relUrl, opts), python: pythonSnippet(relUrl, opts), javascript: jsSnippet(relUrl, opts) } | |
| 271 | + return [ | |
| 272 | + { id: 'curl', label: 'curl', language: 'bash', code: curlSnippet(relUrl, opts) }, | |
| 273 | + { id: 'python', label: 'Python', language: 'python', code: pythonSnippet(relUrl, opts) }, | |
| 274 | + { id: 'javascript', label: 'JavaScript', language: 'javascript', code: jsSnippet(relUrl, opts) }, | |
| 275 | + ] | |
| 233 | 276 | }, [relUrl, authenticated, format, entry]) |
| 234 | 277 | |
| 235 | − const rows = result?.rows || [] | |
| 278 | + // what the results area shows: a live result, or the captured sample while nothing has run | |
| 279 | + const shown = result && !result.error ? result : (!result && !running && sample) || null | |
| 280 | + const showingSample = !!shown?.captured | |
| 281 | + const rows = shown?.rows || [] | |
| 236 | 282 | const chartable = rows.length > 0 && !!detectTimeKey(rows, entry.result?.timeKey) |
| 283 | + const effectiveView = shown ? (showingSample && view === 'download' ? pickView(shown, entry) : view) : 'json' | |
| 284 | + useEffect(() => { if (showingSample && sample) setView(sample.rows?.length ? 'table' : 'json') }, [showingSample, sample, entry]) | |
| 285 | + | |
| 237 | 286 | const viewTabs = [ |
| 238 | − { id: 'json', label: 'JSON', disabled: !result?.json }, | |
| 287 | + { id: 'json', label: 'JSON', disabled: !shown?.json }, | |
| 239 | 288 | { id: 'table', label: 'Table', disabled: !rows.length, badge: rows.length ? rows.length.toLocaleString() : undefined }, |
| 240 | 289 | { id: 'chart', label: 'Chart', disabled: !chartable }, |
| 241 | − { id: 'download', label: 'Download', disabled: !result?.blob }, | |
| 290 | + { id: 'code', label: 'Code' }, | |
| 291 | + ...(shown?.blob ? [{ id: 'download', label: 'Download' }] : []), | |
| 242 | 292 | ] |
| 243 | − const visibleCatalog = CATALOG.filter(e => !filter || `${e.title} ${e.path} ${e.group}`.toLowerCase().includes(filter.toLowerCase())) | |
| 244 | − const jsonDownload = useMemo(() => (result?.json && result.ok ? URL.createObjectURL(new Blob([JSON.stringify(result.json)], { type: 'application/json' })) : null), [result]) | |
| 245 | − useEffect(() => () => { if (jsonDownload) URL.revokeObjectURL(jsonDownload) }, [jsonDownload]) | |
| 293 | + | |
| 294 | + // Code export is always in the DOM (hidden unless the Code tab is active) so the dashboard can assert on it. | |
| 295 | + const codex = visible => ( | |
| 296 | + <div className="pg-codex" data-testid="pg-code" hidden={!visible}> | |
| 297 | + <CodeTabs snippets={codeSnippets} maxHeight={360} footer={authenticated ? 'Your key is read from $HFMD_API_KEY — never pasted into the snippet.' : 'Keyless request — a free API key (sent as a Bearer token) raises the limit to 120 requests / minute.'} /> | |
| 298 | + </div> | |
| 299 | + ) | |
| 246 | 300 | |
| 247 | 301 | return ( |
| 248 | 302 | <div className={`pg ${embedded ? 'pg-embedded' : ''}`} data-testid="playground"> |
| 249 | 303 | {!embedded && !authenticated && ( |
| 250 | − <PgCallout kind="info" className="pg-banner" action={<Link to="/signup" className="btn btn-primary">Create free account</Link>}> | |
| 251 | − <strong>Keyless mode:</strong> 30 requests / hour, 5 000 rows per request. Create a free account for 120 requests / minute and 50 000 rows per request. | |
| 252 | − </PgCallout> | |
| 304 | + <Callout type="info" compact className="pg-banner" action={<Link to="/signup" className="btn btn-primary btn-sm">Create free account</Link>}> | |
| 305 | + <strong>Keyless mode:</strong> 30 requests / hour, 5 000 rows per request. A free account gives 120 requests / minute and 50 000 rows per request. | |
| 306 | + </Callout> | |
| 253 | 307 | )} |
| 254 | 308 | {authenticated && ( |
| 255 | 309 | <div className="pg-authbar" data-testid="pg-authbar"> |
| 256 | 310 | <span className="pg-authdot" aria-hidden="true" /> Authenticated{user?.email ? <> as <strong>{user.email}</strong></> : ''} · tier <span className="pg-tier">{tier}</span> · requests carry <code>Authorization: Bearer $HFMD_API_KEY</code> |
| 257 | 311 | </div> |
| 258 | 312 | )} |
| 259 | − <div className="pg-grid"> | |
| 260 | − {/* ------------------------------------------------ left: request type + form */} | |
| 261 | − <aside className="pg-left"> | |
| 262 | − <div className="pg-left-head"> | |
| 263 | − <h2 className="pg-h">Request type</h2> | |
| 264 | − <input type="search" className="pg-filter" placeholder="Filter…" value={filter} onChange={e => setFilter(e.target.value)} aria-label="Filter request types" /> | |
| 265 | − </div> | |
| 266 | − <nav className="pg-types" aria-label="Request type"> | |
| 267 | − {GROUPS.map(g => { | |
| 268 | − const items = visibleCatalog.filter(e => e.group === g) | |
| 269 | − if (!items.length) return null | |
| 270 | − return ( | |
| 271 | − <div key={g} className="pg-type-group"> | |
| 272 | − <div className="pg-type-group-name">{g}</div> | |
| 273 | − {items.map(e => ( | |
| 274 | − <button key={e.id} type="button" className={`pg-type ${entry.id === e.id ? 'active' : ''}`} aria-current={entry.id === e.id ? 'true' : undefined} | |
| 275 | − onClick={() => selectEntry(e)} data-ep={e.id}> | |
| 276 | − <span className={`pg-method ${e.method.toLowerCase()}`}>{e.method}</span> | |
| 277 | − <span className="pg-type-title">{e.title}</span> | |
| 278 | − <span className="pg-type-path mono">{e.path}</span> | |
| 279 | − </button> | |
| 280 | − ))} | |
| 281 | − </div> | |
| 282 | − ) | |
| 283 | − })} | |
| 284 | − {entry.fromSpec && ( | |
| 285 | − <div className="pg-type-group"><div className="pg-type-group-name">From OpenAPI</div> | |
| 286 | − <button type="button" className="pg-type active" aria-current="true"><span className={`pg-method ${entry.method.toLowerCase()}`}>{entry.method}</span><span className="pg-type-title">{entry.title}</span><span className="pg-type-path mono">{entry.path}</span></button> | |
| 287 | − </div> | |
| 288 | − )} | |
| 289 | − </nav> | |
| 290 | − {!isWs && ( | |
| 291 | − <section className="pg-form-section" aria-label="Parameters"> | |
| 292 | − <h2 className="pg-h">Parameters</h2> | |
| 293 | − {entry.description && <p className="muted pg-desc">{entry.description}</p>} | |
| 294 | − <RequestForm entry={entry} values={values} errors={errors} onChange={onChange} apiKey={apiKey} /> | |
| 295 | − {entry.fixed && <p className="muted pg-fixed">Fixed: {Object.entries(entry.fixed).map(([k, v]) => <code key={k}>{k}={v}</code>)}</p>} | |
| 296 | − </section> | |
| 297 | − )} | |
| 298 | − </aside> | |
| 299 | 313 | |
| 300 | − {/* ------------------------------------------------ right: URL, run, results */} | |
| 301 | − <section className="pg-right" aria-label="Request and results"> | |
| 302 | − {notice && <PgCallout kind={notice.kind}>{notice.text}</PgCallout>} | |
| 303 | − {isWs ? ( | |
| 304 | − <WebSocketTab apiKey={apiKey} authenticated={!!user} /> | |
| 305 | − ) : ( | |
| 306 | − <> | |
| 307 | − <div className="pg-urlbar"> | |
| 308 | − <span className={`pg-method ${entry.method.toLowerCase()}`}>{entry.method}</span> | |
| 309 | − <output className="pg-url mono" data-testid="pg-url" aria-live="polite" htmlFor="">{fullUrl}</output> | |
| 310 | − <PgCopyButton text={fullUrl} label="Copy URL" small /> | |
| 311 | − </div> | |
| 312 | − <div className="pg-runrow"> | |
| 313 | − <button type="button" className="btn btn-primary pg-run" onClick={run} disabled={running || Object.keys(errors).length > 0} data-testid="pg-run"> | |
| 314 | − {running ? 'Running…' : 'Run ▶'} | |
| 315 | − </button> | |
| 316 | − {running && <button type="button" className="btn" onClick={() => { abortRef.current?.abort(); setRunning(false) }}>Cancel</button>} | |
| 317 | − <span className="pg-kbd muted">⌘/Ctrl + Enter</span> | |
| 318 | − {Object.keys(errors).length > 0 && <span className="pg-err">Fix {Object.keys(errors).length} field{Object.keys(errors).length > 1 ? 's' : ''} to run.</span>} | |
| 319 | − {result && ( | |
| 320 | − <span className="pg-status" data-testid="pg-status"> | |
| 321 | − <span className={`pg-code-badge ${result.ok ? 'ok' : 'bad'}`}>{result.status || 'ERR'}{result.ok ? ' OK' : ''}</span> | |
| 322 | − <span className="mono">{result.ms} ms</span> | |
| 323 | − {result.rowCount != null && <span className="mono">{Number(result.rowCount).toLocaleString()} rows</span>} | |
| 324 | − {result.blob && <span className="mono">{(result.blob.size / 1024).toFixed(1)} KB</span>} | |
| 325 | − </span> | |
| 326 | − )} | |
| 327 | − </div> | |
| 314 | + {/* ------------------------------------------------ top bar: endpoint + presets */} | |
| 315 | + <div className="pg-top"> | |
| 316 | + <EndpointPicker entry={entry} onSelect={e => selectEntry(e)} extra={entry.fromSpec ? [entry] : []} /> | |
| 317 | + <div className="pg-presets" aria-label="Examples"> | |
| 318 | + <span className="muted small">Examples</span> | |
| 319 | + {PRESETS.map(ps => <button key={ps.id} type="button" className="pg-preset" onClick={() => applyPreset(ps)} data-preset={ps.id}>{ps.label}</button>)} | |
| 320 | + </div> | |
| 321 | + </div> | |
| 322 | + {notice && <Callout kind={notice.kind} compact>{notice.text}</Callout>} | |
| 328 | 323 | |
| 329 | − {result?.error && ( | |
| 330 | − <PgCallout kind={result.comingSoon ? 'warn' : 'danger'} title={result.comingSoon ? 'Endpoint not available yet' : `${result.error.code || 'Error'} · HTTP ${result.status}`} | |
| 331 | − action={result.status === 429 && !authenticated ? <Link className="btn btn-primary" to="/signup">Create free account</Link> : result.error.docs ? <a className="btn" href={result.error.docs}>Docs</a> : null}> | |
| 332 | − {result.comingSoon | |
| 333 | − ? <>This request type is part of the v2 upgrade and is not deployed on this server yet — coming soon. Route: <code>{entry.method} {entry.path}</code>.</> | |
| 334 | − : result.error.message} | |
| 335 | − {result.error.details && <pre className="pg-err-details">{JSON.stringify(result.error.details, null, 2)}</pre>} | |
| 336 | − </PgCallout> | |
| 324 | + {/* ------------------------------------------------ request */} | |
| 325 | + <section className="pg-request card" aria-label="Request"> | |
| 326 | + <div className="pg-request-head"> | |
| 327 | + <div> | |
| 328 | + <h2 className="pg-request-title">{entry.title}</h2> | |
| 329 | + {entry.description && <p className="muted pg-desc">{entry.description}</p>} | |
| 330 | + </div> | |
| 331 | + {entry.fixed && <p className="muted pg-fixed">Fixed: {Object.entries(entry.fixed).map(([k, v]) => <code key={k}>{k}={v}</code>)}</p>} | |
| 332 | + </div> | |
| 333 | + {isWs ? ( | |
| 334 | + <WebSocketTab apiKey={apiKey} authenticated={!!user} /> | |
| 335 | + ) : ( | |
| 336 | + <> | |
| 337 | + <RequestForm entry={entry} values={values} errors={errors} onChange={onChange} apiKey={apiKey} /> | |
| 338 | + <div className="pg-urlbar"> | |
| 339 | + <span className={`pg-method ${entry.method.toLowerCase()}`}>{entry.method}</span> | |
| 340 | + <output className="pg-url mono" data-testid="pg-url" aria-live="polite" htmlFor="">{fullUrl}</output> | |
| 341 | + <CopyButton text={fullUrl} label="Copy URL" /> | |
| 342 | + </div> | |
| 343 | + <div className="pg-runrow"> | |
| 344 | + <button type="button" className="btn btn-primary pg-run" onClick={run} disabled={running || errorCount > 0} data-testid="pg-run"> | |
| 345 | + <PlayIcon /> {running ? 'Running…' : 'Run'} | |
| 346 | + </button> | |
| 347 | + {running && <button type="button" className="btn" onClick={() => { abortRef.current?.abort(); setRunning(false) }}>Cancel</button>} | |
| 348 | + <span className="pg-kbd muted">⌘/Ctrl + Enter</span> | |
| 349 | + {errorCount > 0 && <span className="pg-err" role="status">Fix {errorCount} field{errorCount > 1 ? 's' : ''} to run.</span>} | |
| 350 | + {result && ( | |
| 351 | + <span className="pg-status" data-testid="pg-status"> | |
| 352 | + <span className={`pg-code-badge ${result.ok ? 'ok' : 'bad'}`}>{result.status || 'ERR'}{result.ok ? ' OK' : ''}</span> | |
| 353 | + <span className="mono">{result.ms} ms</span> | |
| 354 | + {result.rowCount != null && <span className="mono">{Number(result.rowCount).toLocaleString()} rows</span>} | |
| 355 | + {result.blob && <span className="mono">{(result.blob.size / 1024).toFixed(1)} KB</span>} | |
| 356 | + </span> | |
| 337 | 357 | )} |
| 358 | + </div> | |
| 359 | + </> | |
| 360 | + )} | |
| 361 | + </section> | |
| 338 | 362 | |
| 339 | − {result && !result.error && ( | |
| 340 | − <div className="pg-results" ref={resultRef}> | |
| 341 | − <PgTabs tabs={viewTabs} value={view} onChange={setView} label="Result view" right={ | |
| 342 | − <div className="pg-dl-row"> | |
| 343 | − {jsonDownload && <a className="btn btn-sm" href={jsonDownload} download={`${entry.id}.json`}>Download JSON</a>} | |
| 344 | − {downloadUrl && <a className="btn btn-sm" href={downloadUrl} download={result.filename}>Download {format.toUpperCase()}</a>} | |
| 345 | − </div>}> | |
| 346 | − {view === 'json' && (result.json ? <JsonView data={result.json} /> : <pre className="pg-text">{(result.text || '').slice(0, 20_000)}</pre>)} | |
| 347 | − {view === 'table' && <TableView rows={rows} />} | |
| 348 | − {view === 'chart' && <ChartView rows={rows} meta={result.meta} hint={entry.result || {}} />} | |
| 349 | − {view === 'download' && ( | |
| 363 | + {/* ------------------------------------------------ results */} | |
| 364 | + {!isWs && ( | |
| 365 | + <section className="pg-results" aria-label="Results" data-testid="pg-results"> | |
| 366 | + {result?.error && ( | |
| 367 | + result.comingSoon | |
| 368 | + ? <Callout type="warning" title="Endpoint not available yet">This request type is part of the v2 upgrade and is not deployed on this server yet — coming soon. Route: <code>{entry.method} {entry.path}</code>.</Callout> | |
| 369 | + : <ErrorState status={result.status} code={result.error.code} message={result.error.message} retryUntil={result.retryUntil} authenticated={authenticated} docs={result.error.docs} onRetry={run} data-testid="pg-error"> | |
| 370 | + {result.error.details && <pre className="pg-err-details">{JSON.stringify(result.error.details, null, 2)}</pre>} | |
| 371 | + </ErrorState> | |
| 372 | + )} | |
| 373 | + {!result?.error && !shown && ( | |
| 374 | + running | |
| 375 | + ? <EmptyState title="Running…" data-testid="pg-empty"><span className="spinner" aria-hidden="true" /> Waiting for the API.</EmptyState> | |
| 376 | + : <EmptyState icon={PlayIcon} title="Edit a parameter and run" data-testid="pg-empty">Results appear here as JSON, a sortable table or a chart. Nothing is sent until you press Run.</EmptyState> | |
| 377 | + )} | |
| 378 | + {!result?.error && shown && ( | |
| 379 | + <div className="pg-results-box"> | |
| 380 | + {showingSample && ( | |
| 381 | + <div className="pg-sample-note" data-testid="pg-sample"> | |
| 382 | + <span className="badge badge-info">Captured example</span> | |
| 383 | + <span>Real response from production{shown.capturedAt ? ` on ${shown.capturedAt.slice(0, 10)}` : ''} for <code className="pg-sample-url">{shown.url?.replace(PUBLIC_BASE, '')}</code>. <strong>Edit a parameter and run</strong> to query the live API.</span> | |
| 384 | + </div> | |
| 385 | + )} | |
| 386 | + <Tabs items={viewTabs} value={effectiveView} onChange={setView} ariaLabel="Result view" className="tabs-flush" right={ | |
| 387 | + <div className="pg-dl-row"> | |
| 388 | + {jsonDownload && <a className="btn btn-sm" href={jsonDownload} download={`${entry.id}.json`}>Download JSON</a>} | |
| 389 | + {downloadUrl && <a className="btn btn-sm" href={downloadUrl} download={result.filename}>Download {format.toUpperCase()}</a>} | |
| 390 | + </div>}> | |
| 391 | + {v => ( | |
| 392 | + <> | |
| 393 | + {v === 'json' && (shown.json ? <JsonView data={shown.json} /> : <pre className="pg-text">{(shown.text || '').slice(0, 20_000)}</pre>)} | |
| 394 | + {v === 'table' && <TableView rows={rows} />} | |
| 395 | + {v === 'chart' && <ChartView rows={rows} meta={shown.meta} hint={entry.result || {}} />} | |
| 396 | + {v === 'download' && shown.blob && ( | |
| 350 | 397 | <div className="pg-download"> |
| 351 | − <p>{format.toUpperCase()} file ready · {(result.blob.size / 1024).toFixed(1)} KB{result.rowCount != null && ` · ${Number(result.rowCount).toLocaleString()} rows`}.</p> | |
| 352 | − <a className="btn btn-primary" href={downloadUrl} download={result.filename}>Download {result.filename}</a> | |
| 398 | + <p>{format.toUpperCase()} file ready · {(shown.blob.size / 1024).toFixed(1)} KB{shown.rowCount != null && ` · ${Number(shown.rowCount).toLocaleString()} rows`}.</p> | |
| 399 | + <a className="btn btn-primary" href={downloadUrl} download={shown.filename}>Download {shown.filename}</a> | |
| 353 | 400 | {format === 'parquet' && <p className="muted">Parquet responses count half the rows against your quota. Read it with <code>pd.read_parquet</code>.</p>} |
| 354 | 401 | </div> |
| 355 | 402 | )} |
| 356 | − </PgTabs> | |
| 357 | − </div> | |
| 358 | − )} | |
| 359 | − | |
| 360 | − <RateLimitPanel rate={lastRate} tier={tier} authenticated={authenticated} retryUntil={retryUntil} /> | |
| 361 | − | |
| 362 | − <section className="pg-codex" aria-label="Code export"> | |
| 363 | − <PgTabs tabs={[{ id: 'curl', label: 'curl' }, { id: 'python', label: 'Python (requests + pandas)' }, { id: 'javascript', label: 'JavaScript (fetch)' }]} value={codeLang} onChange={setCodeLang} label="Code export"> | |
| 364 | − <PgCodeBlock code={code[codeLang]} language={codeLang === 'curl' ? 'bash' : codeLang} title={`${codeLang}${authenticated ? ' · key via $HFMD_API_KEY' : ''}`} /> | |
| 365 | − </PgTabs> | |
| 366 | − </section> | |
| 367 | − </> | |
| 403 | + </> | |
| 404 | + )} | |
| 405 | + </Tabs> | |
| 406 | + {codex(effectiveView === 'code')} | |
| 407 | + </div> | |
| 368 | 408 | )} |
| 369 | − | |
| 370 | − <section className="pg-presets" aria-label="Examples"> | |
| 371 | − <span className="muted">Examples:</span> | |
| 372 | − {PRESETS.map(ps => <button key={ps.id} type="button" className="pg-preset" onClick={() => applyPreset(ps)} data-preset={ps.id}>{ps.label}</button>)} | |
| 373 | − </section> | |
| 409 | + {(result?.error || !shown) && codex(false)} | |
| 374 | 410 | </section> |
| 375 | − </div> | |
| 411 | + )} | |
| 412 | + | |
| 413 | + {!isWs && <RateLimitPanel rate={lastRate} tier={tier} authenticated={authenticated} retryUntil={retryUntil} />} | |
| 376 | 414 | </div> |
| 377 | 415 | ) |
| 378 | 416 | } |
modified
hfmarketdata/web/src/playground/RateLimitPanel.jsx
+5 −16
@@ -1,7 +1,10 @@ | ||
| 1 | 1 | // Quota panel fed by X-RateLimit-* headers of the last response; Retry-After countdown after a 429. |
| 2 | −import React, { useEffect, useState } from 'react' | |
| 2 | +import React from 'react' | |
| 3 | 3 | import { Link } from 'react-router-dom' |
| 4 | 4 | import { TIERS } from '../app/api.js' |
| 5 | +import { fmtDuration, useCountdown } from '../components/States.jsx' | |
| 6 | + | |
| 7 | +export { useCountdown, fmtDuration } | |
| 5 | 8 | |
| 6 | 9 | const n = v => (v == null ? '—' : Number(v).toLocaleString()) |
| 7 | 10 | |
@@ -18,20 +21,6 @@ function Bar({ label, used, limit, remaining }) { | ||
| 18 | 21 | ) |
| 19 | 22 | } |
| 20 | 23 | |
| 21 | −export function useCountdown(untilEpochSec) { | |
| 22 | − const [left, setLeft] = useState(() => (untilEpochSec ? Math.max(0, untilEpochSec - Math.floor(Date.now() / 1000)) : 0)) | |
| 23 | − useEffect(() => { | |
| 24 | − if (!untilEpochSec) { setLeft(0); return undefined } | |
| 25 | − const tick = () => setLeft(Math.max(0, untilEpochSec - Math.floor(Date.now() / 1000))) | |
| 26 | − tick() | |
| 27 | − const t = setInterval(tick, 1000) | |
| 28 | − return () => clearInterval(t) | |
| 29 | − }, [untilEpochSec]) | |
| 30 | − return left | |
| 31 | −} | |
| 32 | − | |
| 33 | −export const fmtDuration = s => (s >= 3600 ? `${Math.floor(s / 3600)} h ${Math.floor((s % 3600) / 60)} min` : s >= 60 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${s} s`) | |
| 34 | − | |
| 35 | 24 | export default function RateLimitPanel({ rate, tier, authenticated, retryUntil }) { |
| 36 | 25 | const resetLeft = useCountdown(rate?.reset || null) |
| 37 | 26 | const retryLeft = useCountdown(retryUntil || null) |
@@ -42,7 +31,7 @@ export default function RateLimitPanel({ rate, tier, authenticated, retryUntil } | ||
| 42 | 31 | const remRows = rate?.remainingRows |
| 43 | 32 | const known = rate && (rate.limitRequests != null || rate.remainingRequests != null) |
| 44 | 33 | return ( |
| 45 | − <section className="pg-rl card" aria-label="Rate limit"> | |
| 34 | + <section className="pg-rl card card-sm" aria-label="Rate limit"> | |
| 46 | 35 | <div className="pg-rl-head"> |
| 47 | 36 | <strong>Rate limit</strong> |
| 48 | 37 | <span className="muted">{tierInfo.name} · {n(tierInfo.requests)} req / {tierInfo.window} · {n(tierInfo.rows)} rows / {tierInfo.window}</span> |
modified
hfmarketdata/web/src/playground/RequestForm.jsx
+12 −29
@@ -1,5 +1,6 @@ | ||
| 1 | −// Dynamic form generated from a catalog entry. | |
| 1 | +// Dynamic form generated from a catalog entry — a responsive grid of shared <Field> controls. | |
| 2 | 2 | import React from 'react' |
| 3 | +import { Field, Input, Select } from '../components/Field.jsx' | |
| 3 | 4 | import { enumFor } from './catalog.js' |
| 4 | 5 | import SymbolInput from './SymbolInput.jsx' |
| 5 | 6 | |
@@ -10,49 +11,31 @@ export default function RequestForm({ entry, values, errors, onChange, apiKey }) | ||
| 10 | 11 | if (!entry.params.length) return <p className="muted pg-form-empty">This request takes no parameters.</p> |
| 11 | 12 | const asset = values.asset || entry.fixed?.asset |
| 12 | 13 | return ( |
| 13 | − <div className="pg-form"> | |
| 14 | + <div className="pg-form" data-testid="pg-form"> | |
| 14 | 15 | {entry.params.map(prm => { |
| 15 | 16 | const id = `pg-f-${entry.id}-${prm.name}` |
| 16 | − const helpId = prm.help ? `${id}-help` : undefined | |
| 17 | 17 | const err = errors[prm.name] |
| 18 | − const errId = err ? `${id}-err` : undefined | |
| 19 | − const described = [helpId, errId].filter(Boolean).join(' ') || undefined | |
| 20 | 18 | const val = values[prm.name] ?? '' |
| 21 | 19 | const set = v => onChange(prm.name, v) |
| 20 | + const label = <><span className="mono">{prm.name}</span></> | |
| 22 | 21 | let control |
| 23 | 22 | if (prm.type === 'symbol') { |
| 24 | − control = <SymbolInput id={id} value={val} onChange={set} source={prm.source} asset={asset} apiKey={apiKey} placeholder={prm.placeholder} invalid={err} describedBy={described} /> | |
| 23 | + control = <SymbolInput id={id} value={val} onChange={set} source={prm.source} asset={asset} apiKey={apiKey} placeholder={prm.placeholder} invalid={!!err} /> | |
| 25 | 24 | } else if (prm.type === 'enum') { |
| 26 | − const opts = enumFor(prm, values) | |
| 27 | − control = ( | |
| 28 | − <select id={id} value={val} onChange={e => set(e.target.value)} aria-invalid={!!err} aria-describedby={described} className={err ? 'invalid' : ''}> | |
| 29 | − {opts.map(o => <option key={o} value={o}>{o === '' ? '(default)' : o}</option>)} | |
| 30 | − </select> | |
| 31 | − ) | |
| 25 | + control = <Select id={id} value={val} onChange={e => set(e.target.value)} invalid={!!err} options={enumFor(prm, values)} /> | |
| 32 | 26 | } else if (prm.type === 'date' || prm.type === 'datetime') { |
| 33 | − control = ( | |
| 34 | − <input id={id} type={prm.type === 'date' ? 'date' : 'datetime-local'} step={prm.type === 'datetime' ? 1 : undefined} | |
| 35 | − value={toNative(val, prm.type)} onChange={e => set(fromNative(e.target.value, prm.type))} | |
| 36 | − aria-invalid={!!err} aria-describedby={described} className={err ? 'invalid' : ''} /> | |
| 37 | − ) | |
| 27 | + control = <Input id={id} type={prm.type === 'date' ? 'date' : 'datetime-local'} step={prm.type === 'datetime' ? 1 : undefined} value={toNative(val, prm.type)} onChange={e => set(fromNative(e.target.value, prm.type))} invalid={!!err} /> | |
| 38 | 28 | } else if (prm.type === 'number') { |
| 39 | − control = <input id={id} type="number" inputMode="numeric" min={prm.min} max={prm.max} value={val} placeholder={prm.placeholder} onChange={e => set(e.target.value)} aria-invalid={!!err} aria-describedby={described} className={err ? 'invalid' : ''} /> | |
| 29 | + control = <Input id={id} type="number" inputMode="numeric" min={prm.min} max={prm.max} value={val} placeholder={prm.placeholder} onChange={e => set(e.target.value)} invalid={!!err} mono /> | |
| 40 | 30 | } else if (prm.type === 'bool') { |
| 41 | − control = <input id={id} type="checkbox" checked={val === 'true'} onChange={e => set(e.target.checked ? 'true' : '')} aria-describedby={described} /> | |
| 31 | + control = <input id={id} type="checkbox" checked={val === 'true'} onChange={e => set(e.target.checked ? 'true' : '')} /> | |
| 42 | 32 | } else { |
| 43 | − control = <input id={id} type="text" className={`mono ${err ? 'invalid' : ''}`} value={val} placeholder={prm.placeholder} spellCheck={false} onChange={e => set(e.target.value)} aria-invalid={!!err} aria-describedby={described} /> | |
| 33 | + control = <Input id={id} type="text" mono value={val} placeholder={prm.placeholder} spellCheck={false} onChange={e => set(e.target.value)} invalid={!!err} /> | |
| 44 | 34 | } |
| 45 | 35 | return ( |
| 46 | − <div key={prm.name} className={`pg-field pg-field-${prm.type}`}> | |
| 47 | − <label htmlFor={id} className="pg-label"> | |
| 48 | − <span className="mono">{prm.name}</span> | |
| 49 | − {prm.required && <span className="pg-req" aria-label="required">*</span>} | |
| 50 | − <span className="pg-in muted">{prm.in}</span> | |
| 51 | − </label> | |
| 36 | + <Field key={prm.name} id={id} label={label} required={!!prm.required} hint={prm.help} error={err} labelExtra={prm.in} className={`pg-field pg-field-${prm.type}`}> | |
| 52 | 37 | {control} |
| 53 | − {prm.help && <small id={helpId} className="pg-help muted">{prm.help}</small>} | |
| 54 | − {err && <small id={errId} className="pg-err" role="alert">{err}</small>} | |
| 55 | − </div> | |
| 38 | + </Field> | |
| 56 | 39 | ) |
| 57 | 40 | })} |
| 58 | 41 | </div> |
modified
hfmarketdata/web/src/playground/WebSocketTab.jsx
+15 −15
@@ -2,9 +2,8 @@ | ||
| 2 | 2 | import React, { useEffect, useRef, useState } from 'react' |
| 3 | 3 | import { Link } from 'react-router-dom' |
| 4 | 4 | import { BASE_URL } from '../app/api.js' |
| 5 | −import PgCallout from '../components/PgCallout.jsx' | |
| 6 | −import PgCodeBlock from '../components/PgCodeBlock.jsx' | |
| 7 | −import PgTabs from '../components/PgTabs.jsx' | |
| 5 | +import Callout from '../components/Callout.jsx' | |
| 6 | +import CodeTabs from '../components/CodeTabs.jsx' | |
| 8 | 7 | import { wsSnippet } from './codegen.js' |
| 9 | 8 | |
| 10 | 9 | export function wsUrl(apiKey) { |
@@ -17,11 +16,16 @@ export function wsUrl(apiKey) { | ||
| 17 | 16 | |
| 18 | 17 | const MAX_MSGS = 500 |
| 19 | 18 | |
| 19 | +const toTabs = s => [ | |
| 20 | + { id: 'javascript', label: 'JavaScript', language: 'javascript', code: s.js }, | |
| 21 | + { id: 'python', label: 'Python', language: 'python', code: s.python }, | |
| 22 | + { id: 'curl', label: 'websocat', language: 'bash', code: s.curl }, | |
| 23 | +] | |
| 24 | + | |
| 20 | 25 | export default function WebSocketTab({ apiKey, authenticated }) { |
| 21 | 26 | const [state, setState] = useState('idle') // idle | connecting | open | closed | error |
| 22 | 27 | const [msgs, setMsgs] = useState([]) |
| 23 | 28 | const [sub, setSub] = useState('{"action":"subscribe","channel":"filings"}') |
| 24 | − const [lang, setLang] = useState('js') | |
| 25 | 29 | const [info, setInfo] = useState('') |
| 26 | 30 | const wsRef = useRef(null) |
| 27 | 31 | const logRef = useRef(null) |
@@ -46,18 +50,16 @@ export default function WebSocketTab({ apiKey, authenticated }) { | ||
| 46 | 50 | const disconnect = () => { wsRef.current?.close(); wsRef.current = null } |
| 47 | 51 | const send = () => { if (wsRef.current?.readyState === 1 && sub.trim()) { wsRef.current.send(sub); push('out', sub) } } |
| 48 | 52 | |
| 49 | − const snippets = wsSnippet({ hasKey: !!apiKey }) | |
| 53 | + const snippets = toTabs(wsSnippet({ hasKey: !!apiKey })) | |
| 50 | 54 | if (!apiKey) { |
| 51 | 55 | return ( |
| 52 | 56 | <div className="pg-ws"> |
| 53 | − <PgCallout kind="info" title="WebSocket streaming needs an API key" | |
| 54 | − action={authenticated ? <Link className="btn btn-primary" to="/dashboard/keys">Get a key</Link> : <Link className="btn btn-primary" to="/signup">Create free account</Link>}> | |
| 57 | + <Callout type="info" title="WebSocket streaming needs an API key" | |
| 58 | + action={authenticated ? <Link className="btn btn-primary btn-sm" to="/dashboard/keys">Get a key</Link> : <Link className="btn btn-primary btn-sm" to="/signup">Create free account</Link>}> | |
| 55 | 59 | Browsers cannot set an <code>Authorization</code> header on a WebSocket, so <code>/v1/stream</code> authenticates with <code>?api_key=</code>. |
| 56 | 60 | Keyless (IP-only) access is not available for streams: sign in, create a key in the dashboard and open the playground from there. |
| 57 | − </PgCallout> | |
| 58 | − <PgTabs tabs={[{ id: 'js', label: 'JavaScript' }, { id: 'python', label: 'Python' }, { id: 'curl', label: 'websocat' }]} value={lang} onChange={setLang} label="WebSocket examples"> | |
| 59 | − <PgCodeBlock code={snippets[lang]} language={lang === 'curl' ? 'bash' : lang} title={`${lang} · wss://www.hfmarketdata.io/v1/stream`} /> | |
| 60 | − </PgTabs> | |
| 61 | + </Callout> | |
| 62 | + <CodeTabs title="wss://www.hfmarketdata.io/v1/stream" snippets={snippets} maxHeight={320} /> | |
| 61 | 63 | </div> |
| 62 | 64 | ) |
| 63 | 65 | } |
@@ -72,7 +74,7 @@ export default function WebSocketTab({ apiKey, authenticated }) { | ||
| 72 | 74 | : <button type="button" className="btn btn-primary" onClick={connect}>Connect</button>} |
| 73 | 75 | <button type="button" className="btn" onClick={() => setMsgs([])} disabled={!msgs.length}>Clear</button> |
| 74 | 76 | </div> |
| 75 | − {info && <PgCallout kind="warn">{info}</PgCallout>} | |
| 77 | + {info && <Callout type="warning" compact>{info}</Callout>} | |
| 76 | 78 | <label className="pg-ws-sub"> |
| 77 | 79 | <span>Subscribe message (sent on connect; edit and press Send to send again)</span> |
| 78 | 80 | <div className="pg-ws-sub-row"> |
@@ -84,9 +86,7 @@ export default function WebSocketTab({ apiKey, authenticated }) { | ||
| 84 | 86 | {msgs.length === 0 && <div className="muted">No messages yet.</div>} |
| 85 | 87 | {msgs.map((m, i) => <div key={i} className={`pg-ws-msg ${m.kind}`}><span className="muted">{m.t}</span> <span className="pg-ws-dir">{m.kind === 'in' ? '←' : m.kind === 'out' ? '→' : '·'}</span> {m.text}</div>)} |
| 86 | 88 | </div> |
| 87 | − <PgTabs tabs={[{ id: 'js', label: 'JavaScript' }, { id: 'python', label: 'Python' }, { id: 'curl', label: 'websocat' }]} value={lang} onChange={setLang} label="WebSocket examples"> | |
| 88 | − <PgCodeBlock code={snippets[lang]} language={lang === 'curl' ? 'bash' : lang} title={`${lang} · wss://www.hfmarketdata.io/v1/stream`} /> | |
| 89 | − </PgTabs> | |
| 89 | + <CodeTabs title="wss://www.hfmarketdata.io/v1/stream" snippets={snippets} maxHeight={320} /> | |
| 90 | 90 | </div> |
| 91 | 91 | ) |
| 92 | 92 | } |
modified
hfmarketdata/web/src/playground/catalog.js
+19 −1
@@ -67,7 +67,7 @@ export const CATALOG = [ | ||
| 67 | 67 | barsEntry('index-bars', 'Index bars', 'index', 'OHLCV bars for equity and volatility indices (SPX, VIX…).', 'SPX'), |
| 68 | 68 | { |
| 69 | 69 | id: 'futures-continuous-legacy', group: 'Futures', title: 'Futures continuous (legacy)', method: 'GET', path: '/v1/bars/{asset}/{ticker}', |
| 70 | − operationIds: [], | |
| 70 | + operationIds: ['bars_v1_bars__asset___ticker__get'], // resolved by pickCandidate when the deep link carries asset=futures | |
| 71 | 71 | description: 'Front-month continuous series from the v1 bars endpoint: unadjusted, ratio-adjusted or absolute-adjusted rolls.', |
| 72 | 72 | fixed: { asset: 'futures' }, |
| 73 | 73 | params: [ |
@@ -388,6 +388,24 @@ export function enumFor(param, values) { | ||
| 388 | 388 | return param.enum || [] |
| 389 | 389 | } |
| 390 | 390 | |
| 391 | +/** | |
| 392 | + * Make `values` consistent with the entry's enums: a value that is not allowed for the current dependent enum | |
| 393 | + * (e.g. `adjustment` after `asset` came from a deep link) falls back to the param default, else to the first choice. | |
| 394 | + */ | |
| 395 | +export function normalizeValues(entry, values) { | |
| 396 | + const out = { ...values } | |
| 397 | + for (const prm of entry.params) { | |
| 398 | + if (prm.type !== 'enum') continue | |
| 399 | + const allowed = enumFor(prm, out) | |
| 400 | + if (!allowed.length) continue | |
| 401 | + const v = (out[prm.name] ?? '').toString() | |
| 402 | + if (allowed.includes(v)) continue | |
| 403 | + const dflt = prm.default !== undefined ? String(prm.default) : '' | |
| 404 | + out[prm.name] = allowed.includes(dflt) ? dflt : allowed[0] | |
| 405 | + } | |
| 406 | + return out | |
| 407 | +} | |
| 408 | + | |
| 391 | 409 | export function defaultValues(entry) { |
| 392 | 410 | const v = {} |
| 393 | 411 | for (const prm of entry.params) v[prm.name] = prm.default !== undefined ? String(prm.default) : '' |
modified
hfmarketdata/web/src/playground/playground.css
+117 −101
@@ -1,95 +1,102 @@ | ||
| 1 | −/* Playground + shared Pg* components. Prefixed pg- to avoid collisions with web-core styles. */ | |
| 2 | −.pg { display: flex; flex-direction: column; gap: 14px; } | |
| 1 | +/* Playground. Two zones: request (endpoint picker · parameters grid · URL · Run) then full-width results. | |
| 2 | + Prefixed pg- to avoid collisions with shell styles. Tokens only. */ | |
| 3 | +.pg { display: flex; flex-direction: column; gap: 14px; min-width: 0; } | |
| 3 | 4 | .pg-banner { margin: 0; } |
| 4 | −.pg-authbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--fg-1); padding: 8px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); } | |
| 5 | +.pg-authbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: var(--fs-1); color: var(--fg-1); padding: 8px 12px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg-1); } | |
| 5 | 6 | .pg-authdot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); } |
| 6 | 7 | .pg-tier { text-transform: uppercase; font-size: 11px; letter-spacing: .06em; padding: 1px 6px; border-radius: 4px; background: var(--bg-2); border: 1px solid var(--line-2); } |
| 7 | −.pg-grid { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 18px; align-items: start; } | |
| 8 | −.pg-embedded .pg-grid { grid-template-columns: 320px minmax(0, 1fr); } | |
| 9 | −@media (max-width: 960px) { .pg-grid, .pg-embedded .pg-grid { grid-template-columns: 1fr; } } | |
| 10 | 8 | |
| 11 | −.pg-h { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-2); margin: 0 0 8px; font-weight: 600; } | |
| 12 | −.pg-left { display: flex; flex-direction: column; gap: 18px; position: sticky; top: 70px; max-height: calc(100dvh - 90px); overflow: auto; padding-right: 4px; } | |
| 13 | −@media (max-width: 960px) { .pg-left { position: static; max-height: none; } } | |
| 14 | −.pg-left-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } | |
| 15 | −.pg-filter { padding: 4px 8px; font-size: 13px; width: 130px; } | |
| 16 | −.pg-types { display: flex; flex-direction: column; gap: 10px; } | |
| 17 | −.pg-type-group-name { font-size: 11px; color: var(--fg-2); text-transform: uppercase; letter-spacing: .06em; margin: 0 0 4px 6px; } | |
| 18 | −.pg-type { display: grid; grid-template-columns: auto 1fr; column-gap: 8px; width: 100%; text-align: left; padding: 7px 8px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--fg-1); cursor: pointer; font: inherit; } | |
| 19 | −.pg-type:hover { background: var(--bg-1); color: var(--fg); } | |
| 20 | −.pg-type.active { background: var(--bg-1); border-color: var(--line-2); color: var(--fg); } | |
| 21 | −.pg-type-title { font-weight: 500; } | |
| 22 | −.pg-type-path { grid-column: 2; font-size: 11px; color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 23 | −.pg-method { font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .06em; padding: 2px 6px; border-radius: 4px; align-self: center; background: var(--accent); background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); } | |
| 24 | −.pg-method.ws { background: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 18%, transparent); color: var(--accent-2); } | |
| 25 | −.pg-method.post, .pg-method.patch, .pg-method.delete { background: var(--warn); background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); } | |
| 9 | +/* method chip */ | |
| 10 | +.pg-method { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .06em; padding: 3px 6px; border-radius: 4px; align-self: center; background: color-mix(in srgb, var(--get) 18%, transparent); color: var(--get); flex: none; } | |
| 11 | +.pg-method.ws { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); } | |
| 12 | +.pg-method.post, .pg-method.patch, .pg-method.delete { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); } | |
| 26 | 13 | |
| 27 | −.pg-form-section { border-top: 1px solid var(--line); padding-top: 14px; } | |
| 28 | −.pg-desc { font-size: 13px; margin: 0 0 12px; } | |
| 29 | −.pg-form { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; } | |
| 30 | −.pg-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } | |
| 31 | −.pg-field-symbol, .pg-field-text, .pg-field-datetime { grid-column: 1 / -1; } | |
| 32 | −.pg-field input, .pg-field select { width: 100%; padding: 6px 8px; font-size: 13px; } | |
| 33 | −.pg-field input.invalid, .pg-field select.invalid { border-color: var(--danger); } | |
| 34 | −.pg-label { display: flex; align-items: baseline; gap: 6px; font-size: 12px; color: var(--fg-1); } | |
| 35 | −.pg-req { color: var(--danger); } | |
| 36 | −.pg-in { font-size: 10px; margin-left: auto; } | |
| 37 | −.pg-help { font-size: 11px; line-height: 1.35; } | |
| 38 | −.pg-err { color: var(--danger); font-size: 12px; } | |
| 39 | −.pg-fixed { font-size: 12px; margin: 10px 0 0; display: flex; gap: 6px; flex-wrap: wrap; } | |
| 40 | −.pg-form-empty { font-size: 13px; } | |
| 14 | +/* top bar: endpoint picker + presets */ | |
| 15 | +.pg-top { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: start; } | |
| 16 | +.pg-ep { position: relative; min-width: 0; } | |
| 17 | +.pg-ep-trigger { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 48px; padding: 6px 10px 6px 12px; border: 1px solid var(--line-2); border-radius: var(--r-md); background: var(--bg-1); color: var(--fg); cursor: pointer; text-align: left; } | |
| 18 | +.pg-ep-trigger:hover { border-color: var(--line-3); background: var(--bg-2); } | |
| 19 | +.pg-ep-trigger > svg { margin-left: auto; color: var(--fg-2); flex: none; } | |
| 20 | +.pg-ep-text { display: grid; gap: 1px; min-width: 0; } | |
| 21 | +.pg-ep-title { font-weight: 600; font-size: var(--fs-2); line-height: 1.25; } | |
| 22 | +.pg-ep-path { font-size: var(--fs-0); color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 23 | +.pg-ep-pop { position: absolute; z-index: var(--z-drawer); left: 0; right: 0; top: calc(100% + 6px); min-width: min(560px, 100%); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-lg); box-shadow: var(--shadow-2); overflow: hidden; } | |
| 24 | +.pg-ep-search { display: flex; align-items: center; gap: 10px; padding: 6px 12px; border-bottom: 1px solid var(--line); color: var(--fg-2); } | |
| 25 | +.pg-ep-search input { flex: 1; border: 0; background: transparent; min-height: 40px; padding: 0; font-size: var(--fs-3); } | |
| 26 | +.pg-ep-search input:focus-visible { outline: none; } | |
| 27 | +.pg-ep-list { max-height: min(440px, 60vh); overflow-y: auto; padding: 6px; } | |
| 28 | +.pg-ep-group { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-3); padding: 10px 10px 4px; font-weight: 650; } | |
| 29 | +.pg-ep-opt { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 5px 10px; border-radius: var(--r-md); cursor: pointer; color: var(--fg); } | |
| 30 | +.pg-ep-opt[aria-selected="true"] { background: var(--bg-2); } | |
| 31 | +.pg-ep-opt.current .pg-ep-title { color: var(--accent); } | |
| 32 | +.pg-ep-current { margin-left: auto; font-size: var(--fs-0); flex: none; } | |
| 33 | +.pg-ep-empty { padding: 20px 12px; text-align: center; } | |
| 34 | +.pg-ep-foot { display: flex; gap: 14px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--fg-3); font-size: var(--fs-0); } | |
| 35 | +.pg-ep-foot span:last-child { margin-left: auto; } | |
| 36 | +.pg-presets { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 48px; } | |
| 37 | +.pg-preset { background: var(--bg-1); border: 1px solid var(--line-2); color: var(--fg-1); border-radius: 999px; min-height: var(--tap); padding: 0 12px; cursor: pointer; font: inherit; font-size: var(--fs-0); } | |
| 38 | +.pg-preset:hover { color: var(--fg); border-color: var(--line-3); background: var(--bg-2); } | |
| 39 | + | |
| 40 | +/* request card */ | |
| 41 | +.pg-request { display: flex; flex-direction: column; gap: 16px; padding: 18px 20px; } | |
| 42 | +.pg-request-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: flex-start; } | |
| 43 | +.pg-request-title { font-size: var(--fs-4); margin: 0 0 2px; } | |
| 44 | +.pg-desc { font-size: var(--fs-1); margin: 0; max-width: 72ch; } | |
| 45 | +.pg-fixed { font-size: var(--fs-0); margin: 0; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } | |
| 46 | +.pg-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 14px; } | |
| 47 | +.pg-field-symbol, .pg-field-text { grid-column: span 2; } | |
| 48 | +.pg-field-datetime { grid-column: span 2; } | |
| 49 | +.pg-embedded .pg-form { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 50 | +.pg-field .field-label .mono { font-size: var(--fs-1); color: var(--fg); } | |
| 51 | +.pg-form-empty { font-size: var(--fs-1); margin: 0; } | |
| 52 | +.pg-err { color: var(--danger); font-size: var(--fs-0); } | |
| 41 | 53 | |
| 42 | 54 | .pg-symbol { position: relative; } |
| 43 | −.pg-symbol-spin { position: absolute; right: 10px; top: 50%; width: 10px; height: 10px; margin-top: -5px; border: 2px solid var(--line-2); border-top-color: var(--accent); border-radius: 50%; animation: pg-spin .8s linear infinite; } | |
| 44 | −@keyframes pg-spin { to { transform: rotate(360deg); } } | |
| 45 | −.pg-symbol-list { position: absolute; z-index: 20; left: 0; right: 0; top: calc(100% + 4px); margin: 0; padding: 4px; list-style: none; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: 6px; box-shadow: 0 10px 30px rgba(0,0,0,.35); max-height: 240px; overflow: auto; } | |
| 46 | −.pg-symbol-list li { padding: 6px 8px; border-radius: 4px; cursor: pointer; font-size: 13px; display: flex; gap: 8px; } | |
| 55 | +.pg-symbol input { width: 100%; } | |
| 56 | +.pg-symbol-spin { position: absolute; right: 10px; top: 50%; width: 10px; height: 10px; margin-top: -5px; border: 2px solid var(--line-2); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; } | |
| 57 | +.pg-symbol-list { position: absolute; z-index: 20; left: 0; right: 0; top: calc(100% + 4px); margin: 0; padding: 4px; list-style: none; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-sm); box-shadow: var(--shadow-2); max-height: 240px; overflow: auto; } | |
| 58 | +.pg-symbol-list li { padding: 8px 8px; border-radius: 4px; cursor: pointer; font-size: var(--fs-1); display: flex; gap: 8px; min-height: 36px; align-items: center; } | |
| 47 | 59 | .pg-symbol-list li.active { background: var(--bg-2); } |
| 48 | 60 | |
| 49 | −.pg-right { display: flex; flex-direction: column; gap: 14px; min-width: 0; } | |
| 50 | −.pg-urlbar { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); } | |
| 51 | −.pg-url { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 13px; color: var(--fg); } | |
| 61 | +.pg-urlbar { display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 10px; background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--r-md); } | |
| 62 | +.pg-url { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-1); color: var(--code-fg); } | |
| 52 | 63 | .pg-runrow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } |
| 53 | −.pg-run { min-width: 110px; justify-content: center; } | |
| 54 | −.pg-run:disabled { opacity: .55; cursor: not-allowed; } | |
| 55 | −.pg-kbd { font-size: 12px; } | |
| 56 | −.pg-status { display: inline-flex; align-items: center; gap: 10px; font-size: 13px; } | |
| 57 | −.pg-code-badge { padding: 2px 8px; border-radius: 4px; font-family: var(--mono); font-size: 12px; font-weight: 600; } | |
| 58 | −.pg-code-badge.ok { background: var(--accent); background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); } | |
| 59 | −.pg-code-badge.bad { background: var(--danger); background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); } | |
| 60 | −.pg-err-details { margin: 8px 0 0; font-size: 12px; } | |
| 64 | +.pg-run { min-width: 120px; } | |
| 65 | +.pg-kbd { font-size: var(--fs-0); } | |
| 66 | +.pg-status { display: inline-flex; align-items: center; gap: 10px; font-size: var(--fs-1); flex-wrap: wrap; } | |
| 67 | +.pg-code-badge { padding: 2px 8px; border-radius: 4px; font-family: var(--mono); font-size: var(--fs-0); font-weight: 600; } | |
| 68 | +.pg-code-badge.ok { background: color-mix(in srgb, var(--success) 18%, transparent); color: var(--success); } | |
| 69 | +.pg-code-badge.bad { background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); } | |
| 70 | +.pg-err-details { margin: 8px 0 0; font-size: var(--fs-0); text-align: left; max-width: 100%; } | |
| 61 | 71 | |
| 62 | −.pg-results { border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); overflow: hidden; } | |
| 63 | −.pg-tabs { display: flex; flex-direction: column; } | |
| 64 | −.pg-tablist-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--line); padding: 0 8px; flex-wrap: wrap; } | |
| 65 | −.pg-tablist { display: flex; gap: 2px; } | |
| 66 | −.pg-tab { background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--fg-1); padding: 10px 12px; cursor: pointer; font: inherit; font-size: 13px; display: inline-flex; gap: 6px; align-items: center; } | |
| 67 | −.pg-tab:hover:not(:disabled) { color: var(--fg); } | |
| 68 | −.pg-tab.active { color: var(--fg); border-bottom-color: var(--accent); } | |
| 69 | −.pg-tab:disabled { opacity: .4; cursor: not-allowed; } | |
| 70 | −.pg-tab-badge { font-family: var(--mono); font-size: 11px; background: var(--bg-2); padding: 0 6px; border-radius: 10px; } | |
| 71 | −.pg-tabpanel { min-width: 0; } | |
| 72 | +/* results */ | |
| 73 | +.pg-results { min-width: 0; } | |
| 74 | +.pg-results-box { border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); overflow: hidden; } | |
| 75 | +.pg-results-box .tabs-bar { padding: 0 8px; } | |
| 76 | +.pg-sample-note { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid var(--line); font-size: var(--fs-1); color: var(--fg-1); background: color-mix(in srgb, var(--info) 5%, var(--bg-1)); } | |
| 77 | +.pg-sample-url { font-size: var(--fs-0); } | |
| 72 | 78 | .pg-dl-row { display: flex; gap: 6px; } |
| 73 | −.btn-sm { padding: 4px 10px; font-size: 12px; } | |
| 74 | −.pg-view-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; font-size: 12px; border-bottom: 1px solid var(--line); flex-wrap: wrap; } | |
| 79 | +.pg-view-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; font-size: var(--fs-0); border-bottom: 1px solid var(--line); flex-wrap: wrap; } | |
| 75 | 80 | .pg-empty { padding: 20px 12px; margin: 0; } |
| 76 | −.pg-text { margin: 0; border: 0; border-radius: 0; max-height: 460px; font-size: 12px; } | |
| 81 | +.pg-text { margin: 0; border: 0; border-radius: 0; max-height: 460px; font-size: var(--fs-0); } | |
| 82 | +.pg-codex { padding: 12px 14px 4px; } | |
| 83 | +.pg-codex .codetabs { margin: 0; } | |
| 77 | 84 | |
| 78 | −.pg-json-tree { padding: 10px 12px; font-size: 12.5px; line-height: 1.55; max-height: 520px; overflow: auto; } | |
| 85 | +.pg-json-tree { padding: 10px 12px; font-size: var(--fs-0); line-height: 1.6; max-height: 520px; overflow: auto; } | |
| 79 | 86 | .pg-j-line { white-space: pre; } |
| 80 | 87 | .pg-j-toggle { background: none; border: 0; color: var(--fg-2); cursor: pointer; padding: 0 4px 0 0; font: inherit; } |
| 81 | −.pg-j-key { color: var(--accent-2); } | |
| 82 | −.pg-j-str { color: var(--accent); } | |
| 83 | −.pg-j-num { color: #ffb86b; } | |
| 84 | −.pg-j-bool { color: #c792ea; } | |
| 88 | +.pg-j-key { color: var(--code-key); } | |
| 89 | +.pg-j-str { color: var(--code-string); } | |
| 90 | +.pg-j-num { color: var(--code-number); } | |
| 91 | +.pg-j-bool { color: var(--code-keyword); } | |
| 85 | 92 | .pg-j-null { color: var(--fg-2); font-style: italic; } |
| 86 | 93 | .pg-j-brace { color: var(--fg-1); } |
| 87 | −.pg-j-more { background: none; border: 0; color: var(--accent-2); cursor: pointer; font: inherit; font-size: 12px; text-decoration: underline; } | |
| 94 | +.pg-j-more { background: none; border: 0; color: var(--accent-2); cursor: pointer; font: inherit; font-size: var(--fs-0); text-decoration: underline; } | |
| 88 | 95 | |
| 89 | 96 | .pg-table-wrap { overflow: auto; position: relative; } |
| 90 | −.pg-table { font-size: 12.5px; border-collapse: separate; border-spacing: 0; } | |
| 97 | +.pg-table { font-size: var(--fs-0); border-collapse: separate; border-spacing: 0; } | |
| 91 | 98 | .pg-table th { position: sticky; top: 0; background: var(--bg-2); z-index: 1; padding: 0; white-space: nowrap; } |
| 92 | −.pg-th-btn { background: none; border: 0; color: var(--fg-1); font: inherit; font-size: 12px; font-weight: 600; padding: 8px 10px; cursor: pointer; width: 100%; text-align: left; } | |
| 99 | +.pg-th-btn { background: none; border: 0; color: var(--fg-1); font: inherit; font-size: var(--fs-0); font-weight: 600; padding: 8px 10px; cursor: pointer; width: 100%; text-align: left; min-height: var(--tap); } | |
| 93 | 100 | .pg-th-btn:hover { color: var(--fg); } |
| 94 | 101 | .pg-th-idx, .pg-td-idx { width: 48px; text-align: right; font-size: 11px; } |
| 95 | 102 | .pg-table td { padding: 0 10px; white-space: nowrap; border-bottom: 1px solid var(--line); } |
@@ -98,52 +105,61 @@ | ||
| 98 | 105 | .pg-chart-canvas { width: 100%; } |
| 99 | 106 | .pg-chart-controls { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; } |
| 100 | 107 | .pg-chart-controls label { display: inline-flex; gap: 6px; align-items: center; } |
| 101 | −.pg-chart-controls select { padding: 3px 6px; font-size: 12px; } | |
| 108 | +.pg-chart-controls select { min-height: 30px; padding: 0 6px; font-size: var(--fs-0); } | |
| 102 | 109 | .pg-chart-note { font-size: 11px; margin: 0; padding: 6px 12px; } |
| 103 | 110 | .pg-download { padding: 20px 14px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; } |
| 104 | 111 | |
| 105 | −.pg-rl { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; } | |
| 106 | −.pg-rl-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 13px; } | |
| 107 | −.pg-rl-row { display: flex; flex-direction: column; gap: 4px; font-size: 12px; } | |
| 112 | +/* rate limit */ | |
| 113 | +.pg-rl { display: flex; flex-direction: column; gap: 10px; } | |
| 114 | +.pg-rl-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: var(--fs-1); } | |
| 115 | +.pg-rl-row { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-0); } | |
| 108 | 116 | .pg-rl-label { display: flex; justify-content: space-between; color: var(--fg-1); } |
| 109 | 117 | .pg-rl-bar { height: 6px; border-radius: 3px; background: var(--bg-2); overflow: hidden; } |
| 110 | 118 | .pg-rl-fill { height: 100%; background: var(--accent); transition: width .3s; } |
| 111 | 119 | .pg-rl-fill.warn { background: var(--warn); } |
| 112 | 120 | .pg-rl-fill.danger { background: var(--danger); } |
| 113 | −.pg-rl-foot { font-size: 12px; margin: 0; } | |
| 114 | −.pg-rl-429 { font-size: 13px; padding: 8px 10px; border-radius: 6px; background: var(--danger); background: color-mix(in srgb, var(--danger) 15%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 40%, transparent); } | |
| 115 | − | |
| 116 | −.pg-code { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: #011627; } | |
| 117 | −.pg-code-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--bg-2); border-bottom: 1px solid var(--line); font-size: 12px; color: var(--fg-1); } | |
| 118 | −.pg-code-pre { margin: 0; border: 0; border-radius: 0; padding: 12px 14px; font-size: 12.5px; line-height: 1.5; overflow: auto; } | |
| 119 | −.pg-codex .pg-tablist-row { border-bottom: 0; padding: 0; } | |
| 120 | −.pg-codex .pg-tab.active { border-bottom-color: var(--accent); } | |
| 121 | − | |
| 122 | −.pg-presets { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; } | |
| 123 | −.pg-preset { background: var(--bg-1); border: 1px solid var(--line-2); color: var(--fg-1); border-radius: 999px; padding: 4px 12px; cursor: pointer; font: inherit; font-size: 12.5px; } | |
| 124 | −.pg-preset:hover { color: var(--fg); border-color: var(--fg-2); } | |
| 125 | − | |
| 126 | −.pg-callout { display: flex; justify-content: space-between; gap: 14px; align-items: center; padding: 12px 14px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--bg-1); font-size: 13.5px; flex-wrap: wrap; } | |
| 127 | −.pg-callout-info { border-color: color-mix(in srgb, var(--accent-2) 45%, transparent); background: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 8%, var(--bg-1)); } | |
| 128 | −.pg-callout-warn { border-color: color-mix(in srgb, var(--warn) 45%, transparent); background: var(--warn); background: color-mix(in srgb, var(--warn) 8%, var(--bg-1)); } | |
| 129 | −.pg-callout-danger { border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: var(--danger); background: color-mix(in srgb, var(--danger) 8%, var(--bg-1)); } | |
| 130 | −.pg-callout-success { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--bg-1)); } | |
| 131 | −.pg-callout-body { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 220px; } | |
| 132 | −.pg-callout-title { font-size: 14px; } | |
| 121 | +.pg-rl-foot { font-size: var(--fs-0); margin: 0; } | |
| 122 | +.pg-rl-429 { font-size: var(--fs-1); padding: 8px 10px; border-radius: var(--r-sm); background: color-mix(in srgb, var(--warn) 12%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); } | |
| 133 | 123 | |
| 124 | +/* websocket */ | |
| 134 | 125 | .pg-ws { display: flex; flex-direction: column; gap: 12px; } |
| 135 | −.pg-ws-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 10px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); } | |
| 126 | +.pg-ws-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 10px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-md); } | |
| 136 | 127 | .pg-ws-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--fg-2); } |
| 137 | 128 | .pg-ws-dot.open { background: var(--accent); } |
| 138 | 129 | .pg-ws-dot.connecting { background: var(--warn); } |
| 139 | 130 | .pg-ws-dot.error { background: var(--danger); } |
| 140 | −.pg-ws-state { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-1); min-width: 80px; } | |
| 141 | −.pg-ws-url { flex: 1; font-size: 12px; } | |
| 142 | −.pg-ws-sub { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--fg-1); } | |
| 131 | +.pg-ws-state { font-size: var(--fs-0); text-transform: uppercase; letter-spacing: .06em; color: var(--fg-1); min-width: 80px; } | |
| 132 | +.pg-ws-url { flex: 1; font-size: var(--fs-0); } | |
| 133 | +.pg-ws-sub { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-0); color: var(--fg-1); } | |
| 143 | 134 | .pg-ws-sub-row { display: flex; gap: 8px; } |
| 144 | −.pg-ws-sub-row input { flex: 1; font-size: 12.5px; } | |
| 145 | −.pg-ws-log { height: 260px; overflow: auto; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px; font-size: 12px; line-height: 1.5; } | |
| 135 | +.pg-ws-sub-row input { flex: 1; font-size: var(--fs-1); } | |
| 136 | +.pg-ws-log { height: 260px; overflow: auto; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-md); padding: 8px 10px; font-size: var(--fs-0); line-height: 1.5; } | |
| 146 | 137 | .pg-ws-msg { white-space: pre-wrap; overflow-wrap: anywhere; } |
| 147 | 138 | .pg-ws-msg.in .pg-ws-dir { color: var(--accent); } |
| 148 | 139 | .pg-ws-msg.out .pg-ws-dir { color: var(--accent-2); } |
| 149 | 140 | .pg-ws-msg.sys { color: var(--fg-2); } |
| 141 | + | |
| 142 | +/* page */ | |
| 143 | +.pg-page-head { margin-bottom: 18px; max-width: 760px; } | |
| 144 | +.pg-page-head h1 { margin-bottom: 4px; } | |
| 145 | +.pg-page-head p { margin: 0; } | |
| 146 | + | |
| 147 | +@media (max-width: 1200px) { | |
| 148 | + .pg-form { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 149 | +} | |
| 150 | +@media (max-width: 960px) { | |
| 151 | + .pg-top { grid-template-columns: 1fr; } | |
| 152 | + .pg-form, .pg-embedded .pg-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 153 | + .pg-request { padding: 14px; } | |
| 154 | + .pg-runrow { position: sticky; bottom: 0; z-index: var(--z-sticky); margin: 0 -14px -14px; padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); background: color-mix(in srgb, var(--bg-1) 92%, transparent); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border-top: 1px solid var(--line); border-radius: 0 0 var(--r-lg) var(--r-lg); } | |
| 155 | + .pg-run { min-height: 44px; flex: 1 1 140px; } | |
| 156 | + .pg-kbd { display: none; } | |
| 157 | + .pg-ep-pop { position: fixed; left: 8px; right: 8px; top: calc(var(--topbar-h) + 8px); } | |
| 158 | + .pg-ep-list { max-height: calc(100dvh - var(--topbar-h) - 150px); } | |
| 159 | +} | |
| 160 | +@media (max-width: 640px) { | |
| 161 | + .pg-form, .pg-embedded .pg-form { grid-template-columns: 1fr 1fr; } | |
| 162 | + .pg-field-symbol, .pg-field-text, .pg-field-datetime, .pg-field-date { grid-column: 1 / -1; } | |
| 163 | + .pg-dl-row { display: none; } | |
| 164 | + .pg-results-box .tabs-bar { padding: 0 4px; } | |
| 165 | +} | |
modified
hfmarketdata/web/src/playground/views/ChartView.jsx
+9 −7
@@ -1,4 +1,5 @@ | ||
| 1 | 1 | // Chart view: candlesticks when OHLC is present, otherwise a line on one numeric column. Roll dates → markers. |
| 2 | +// Colours come from the semantic tokens (--up / --down / --accent-2 / --warn); the TradingView attribution stays on (Apache-2.0). | |
| 2 | 3 | import React, { useEffect, useMemo, useRef, useState } from 'react' |
| 3 | 4 | import { createChart, ColorType, CrosshairMode } from 'lightweight-charts' |
| 4 | 5 | |
@@ -55,21 +56,22 @@ export default function ChartView({ rows, meta, hint = {}, height = 420 }) { | ||
| 55 | 56 | if (!el) return undefined |
| 56 | 57 | if (!timeKey) { setMsg('No time column found — nothing to plot.'); return undefined } |
| 57 | 58 | const css = getComputedStyle(document.documentElement) |
| 59 | + const v = name => css.getPropertyValue(name).trim() | |
| 58 | 60 | const chart = createChart(el, { |
| 59 | 61 | height, autoSize: true, |
| 60 | − layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: css.getPropertyValue('--fg-1').trim() || '#aeb6c2', fontFamily: css.getPropertyValue('--sans') }, | |
| 61 | − grid: { vertLines: { color: css.getPropertyValue('--line').trim() || '#262b34' }, horzLines: { color: css.getPropertyValue('--line').trim() || '#262b34' } }, | |
| 62 | + layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: v('--fg-1'), fontFamily: v('--sans'), attributionLogo: true }, | |
| 63 | + grid: { vertLines: { color: v('--line') }, horzLines: { color: v('--line') } }, | |
| 62 | 64 | crosshair: { mode: CrosshairMode.Normal }, |
| 63 | − rightPriceScale: { borderColor: css.getPropertyValue('--line-2').trim() }, | |
| 64 | − timeScale: { borderColor: css.getPropertyValue('--line-2').trim(), timeVisible: true, secondsVisible: false }, | |
| 65 | + rightPriceScale: { borderColor: v('--line-2') }, | |
| 66 | + timeScale: { borderColor: v('--line-2'), timeVisible: true, secondsVisible: false }, | |
| 65 | 67 | }) |
| 66 | 68 | let series |
| 67 | 69 | let points = [] |
| 68 | 70 | if (mode === 'candles' && ohlc) { |
| 69 | − series = chart.addCandlestickSeries({ upColor: '#5ee7a5', downColor: '#ff6b6b', borderVisible: false, wickUpColor: '#5ee7a5', wickDownColor: '#ff6b6b' }) | |
| 71 | + series = chart.addCandlestickSeries({ upColor: v('--up'), downColor: v('--down'), borderVisible: false, wickUpColor: v('--up'), wickDownColor: v('--down') }) | |
| 70 | 72 | for (const r of rows) { const t = toTime(r[timeKey]); if (t != null) points.push({ time: t, open: r.open, high: r.high, low: r.low, close: r.close }) } |
| 71 | 73 | } else { |
| 72 | − series = chart.addLineSeries({ color: '#37b3ff', lineWidth: 2, priceLineVisible: false }) | |
| 74 | + series = chart.addLineSeries({ color: v('--accent-2'), lineWidth: 2, priceLineVisible: false }) | |
| 73 | 75 | for (const r of rows) { const t = toTime(r[timeKey]); const v = r[col]; if (t != null && typeof v === 'number' && Number.isFinite(v)) points.push({ time: t, value: v }) } |
| 74 | 76 | } |
| 75 | 77 | points = dedupeSorted(points) |
@@ -81,7 +83,7 @@ export default function ChartView({ rows, meta, hint = {}, height = 420 }) { | ||
| 81 | 83 | const markers = rolls.map(rd => { |
| 82 | 84 | const d = typeof rd === 'string' ? rd : rd?.date || rd?.datetime || rd?.roll_date |
| 83 | 85 | const t = toTime(d) |
| 84 | − return t == null ? null : { time: t, position: 'belowBar', color: '#ffc857', shape: 'arrowUp', text: typeof rd === 'object' && rd?.to ? `roll → ${rd.to}` : 'roll' } | |
| 86 | + return t == null ? null : { time: t, position: 'belowBar', color: v('--warn'), shape: 'arrowUp', text: typeof rd === 'object' && rd?.to ? `roll → ${rd.to}` : 'roll' } | |
| 85 | 87 | }).filter(m => m && (times.has(String(m.time)) || typeof m.time === 'string')) |
| 86 | 88 | try { series.setMarkers(dedupeSorted(markers)) } catch { /* marker outside range */ } |
| 87 | 89 | } |
modified
hfmarketdata/web/src/playground/views/JsonView.jsx
+2 −2
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | // Collapsible, syntax-coloured JSON tree. Large arrays are paginated (PAGE children at a time) to stay responsive. |
| 2 | 2 | import React, { useState } from 'react' |
| 3 | −import PgCopyButton from '../../components/PgCopyButton.jsx' | |
| 3 | +import CopyButton from '../../components/CopyButton.jsx' | |
| 4 | 4 | |
| 5 | 5 | const PAGE = 100 |
| 6 | 6 | |
@@ -56,7 +56,7 @@ export default function JsonView({ data }) { | ||
| 56 | 56 | <div className="pg-json"> |
| 57 | 57 | <div className="pg-view-toolbar"> |
| 58 | 58 | <span className="muted">{Array.isArray(data?.data) ? `${data.data.length.toLocaleString()} rows in data[]` : 'JSON'}</span> |
| 59 | − <PgCopyButton text={text} label="Copy JSON" small /> | |
| 59 | + <CopyButton text={text} label="Copy JSON" /> | |
| 60 | 60 | </div> |
| 61 | 61 | <div className="pg-json-tree mono" tabIndex={0}> |
| 62 | 62 | <Node v={data} depth={0} defaultOpen /> |
deleted
hfmarketdata/web/src/styles.css
+0 −581
@@ -1,581 +0,0 @@ | ||
| 1 | −/* HF Market Data — refined editorial light theme | |
| 2 | − Author: Simon-Pierre Boucher <contact@spboucher.ai> */ | |
| 3 | − | |
| 4 | −:root { | |
| 5 | − --bg: #fbfaf7; | |
| 6 | − --bg-soft: #f4f2ec; | |
| 7 | − --card: #ffffff; | |
| 8 | − --line: #e7e3d8; | |
| 9 | − --line-strong: #d8d3c4; | |
| 10 | − --ink: #1c2430; | |
| 11 | − --ink-soft: #4a5568; | |
| 12 | − --muted: #8a8f98; | |
| 13 | − --accent: #2e5ce6; | |
| 14 | − --accent-deep: #1e3fa8; | |
| 15 | − --accent-wash: #eef2fd; | |
| 16 | − --mono: 'JetBrains Mono', ui-monospace, monospace; | |
| 17 | − --sans: 'Inter', system-ui, sans-serif; | |
| 18 | − --serif: 'Fraunces', Georgia, serif; | |
| 19 | − --shadow: 0 1px 2px rgba(28, 36, 48, 0.05), 0 8px 24px -12px rgba(28, 36, 48, 0.12); | |
| 20 | − --shadow-sm: 0 1px 2px rgba(28, 36, 48, 0.05), 0 4px 12px -6px rgba(28, 36, 48, 0.08); | |
| 21 | −} | |
| 22 | − | |
| 23 | −* { box-sizing: border-box; } | |
| 24 | −html { scroll-behavior: smooth; scroll-padding-top: 24px; } | |
| 25 | −body { | |
| 26 | − margin: 0; | |
| 27 | − font-family: var(--sans); | |
| 28 | − background: var(--bg); | |
| 29 | − color: var(--ink); | |
| 30 | − line-height: 1.7; | |
| 31 | − font-size: 15.5px; | |
| 32 | − -webkit-font-smoothing: antialiased; | |
| 33 | −} | |
| 34 | − | |
| 35 | −a { color: var(--accent); text-decoration: none; } | |
| 36 | −a:hover { text-decoration: underline; text-underline-offset: 3px; } | |
| 37 | − | |
| 38 | −code { | |
| 39 | − font-family: var(--mono); | |
| 40 | − font-size: 0.82em; | |
| 41 | − background: var(--bg-soft); | |
| 42 | − border: 1px solid var(--line); | |
| 43 | − border-radius: 5px; | |
| 44 | − padding: 1px 6px; | |
| 45 | − margin: 0 2px; | |
| 46 | − white-space: nowrap; | |
| 47 | − color: var(--accent-deep); | |
| 48 | −} | |
| 49 | − | |
| 50 | −pre { | |
| 51 | − background: var(--bg-soft); | |
| 52 | − border: 1px solid var(--line); | |
| 53 | − border-radius: 10px; | |
| 54 | − padding: 18px 20px; | |
| 55 | − overflow-x: auto; | |
| 56 | − margin: 0; | |
| 57 | −} | |
| 58 | −pre code { | |
| 59 | − background: none; border: none; padding: 0; margin: 0; | |
| 60 | − font-size: 0.8rem; white-space: pre; | |
| 61 | − color: var(--ink-soft); | |
| 62 | −} | |
| 63 | − | |
| 64 | −/* ---------------------------------------------------------------- layout */ | |
| 65 | − | |
| 66 | −.layout { display: flex; min-height: 100vh; } | |
| 67 | − | |
| 68 | −.sidebar { | |
| 69 | − width: 268px; | |
| 70 | − flex-shrink: 0; | |
| 71 | − border-right: 1px solid var(--line); | |
| 72 | − padding: 30px 22px 40px; | |
| 73 | − position: sticky; | |
| 74 | − top: 0; | |
| 75 | − height: 100vh; | |
| 76 | − overflow-y: auto; | |
| 77 | − background: var(--bg); | |
| 78 | −} | |
| 79 | − | |
| 80 | −main { | |
| 81 | − flex: 1; | |
| 82 | − max-width: 880px; | |
| 83 | − padding: 56px 56px 60px; | |
| 84 | − margin: 0 auto; | |
| 85 | − min-width: 0; | |
| 86 | −} | |
| 87 | −section { margin-bottom: 84px; } | |
| 88 | − | |
| 89 | −/* brand */ | |
| 90 | −.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 30px; color: var(--ink); } | |
| 91 | −.brand:hover { text-decoration: none; } | |
| 92 | −.logo { | |
| 93 | − display: grid; place-items: center; | |
| 94 | − width: 40px; height: 40px; | |
| 95 | − background: var(--accent); | |
| 96 | − color: white; | |
| 97 | − border-radius: 10px; | |
| 98 | − font-family: var(--serif); | |
| 99 | − font-weight: 600; | |
| 100 | − font-size: 0.95rem; | |
| 101 | − box-shadow: var(--shadow-sm); | |
| 102 | −} | |
| 103 | −.brand-text { display: flex; flex-direction: column; line-height: 1.3; } | |
| 104 | −.brand-name { font-family: var(--serif); font-weight: 600; font-size: 1.02rem; letter-spacing: 0.01em; } | |
| 105 | −.brand-sub { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; } | |
| 106 | − | |
| 107 | −/* nav */ | |
| 108 | −.sidebar nav { display: flex; flex-direction: column; gap: 1px; } | |
| 109 | −.kicker { | |
| 110 | − font-size: 0.66rem; | |
| 111 | − font-weight: 700; | |
| 112 | − letter-spacing: 0.16em; | |
| 113 | − text-transform: uppercase; | |
| 114 | − color: var(--muted); | |
| 115 | − margin: 20px 0 8px; | |
| 116 | −} | |
| 117 | −.hero .kicker, section > .kicker, .ep-group > .kicker { color: var(--accent); } | |
| 118 | −.sidebar nav a { | |
| 119 | − color: var(--ink-soft); | |
| 120 | − font-size: 0.86rem; | |
| 121 | − padding: 5px 10px; | |
| 122 | − border-radius: 7px; | |
| 123 | − transition: background 0.12s, color 0.12s; | |
| 124 | −} | |
| 125 | −.sidebar nav a:hover { | |
| 126 | − background: var(--accent-wash); | |
| 127 | − color: var(--accent-deep); | |
| 128 | − text-decoration: none; | |
| 129 | −} | |
| 130 | −.ep-link { display: flex; align-items: baseline; gap: 8px; } | |
| 131 | −.ep-method { | |
| 132 | − font-family: var(--mono); | |
| 133 | − font-size: 0.56rem; | |
| 134 | − font-weight: 600; | |
| 135 | − color: var(--accent); | |
| 136 | − letter-spacing: 0.06em; | |
| 137 | −} | |
| 138 | − | |
| 139 | −/* ---------------------------------------------------------------- hero */ | |
| 140 | − | |
| 141 | −.hero { padding-top: 8px; } | |
| 142 | −.hero h1 { | |
| 143 | − font-family: var(--serif); | |
| 144 | − font-size: clamp(2.7rem, 5.5vw, 4rem); | |
| 145 | − font-weight: 500; | |
| 146 | − line-height: 1.06; | |
| 147 | − letter-spacing: -0.01em; | |
| 148 | − margin: 16px 0 22px; | |
| 149 | −} | |
| 150 | −.accent-word { | |
| 151 | − font-style: italic; | |
| 152 | − color: var(--accent); | |
| 153 | −} | |
| 154 | −.lede { font-size: 1.1rem; color: var(--ink-soft); max-width: 620px; } | |
| 155 | −.lede strong { color: var(--ink); font-weight: 600; } | |
| 156 | − | |
| 157 | −.hero-actions { display: flex; gap: 12px; margin: 28px 0 40px; flex-wrap: wrap; } | |
| 158 | −.btn { | |
| 159 | − display: inline-block; | |
| 160 | − font-weight: 600; | |
| 161 | − font-size: 0.9rem; | |
| 162 | − background: var(--accent); | |
| 163 | − color: white; | |
| 164 | − padding: 11px 26px; | |
| 165 | − border: 1px solid var(--accent); | |
| 166 | − border-radius: 999px; | |
| 167 | − box-shadow: var(--shadow-sm); | |
| 168 | − cursor: pointer; | |
| 169 | − transition: background 0.12s, transform 0.12s; | |
| 170 | −} | |
| 171 | −.btn:hover { text-decoration: none; background: var(--accent-deep); transform: translateY(-1px); } | |
| 172 | −.btn.ghost { | |
| 173 | − background: var(--card); | |
| 174 | − color: var(--ink); | |
| 175 | − border-color: var(--line-strong); | |
| 176 | −} | |
| 177 | −.btn.ghost:hover { background: var(--bg-soft); } | |
| 178 | − | |
| 179 | −/* stat row */ | |
| 180 | −.stat-chips { display: flex; flex-wrap: wrap; gap: 0; } | |
| 181 | −.chip { | |
| 182 | − display: flex; | |
| 183 | − flex-direction: column; | |
| 184 | − padding: 4px 28px 4px 18px; | |
| 185 | − border-left: 1px solid var(--line-strong); | |
| 186 | − min-width: 120px; | |
| 187 | −} | |
| 188 | −.chip b { | |
| 189 | − font-family: var(--serif); | |
| 190 | − font-size: 1.7rem; | |
| 191 | − font-weight: 500; | |
| 192 | − color: var(--ink); | |
| 193 | − line-height: 1.15; | |
| 194 | −} | |
| 195 | −.chip span { font-size: 0.74rem; color: var(--muted); } | |
| 196 | −.hero-chips { margin-top: 0; } | |
| 197 | − | |
| 198 | −/* ---------------------------------------------------------------- headings */ | |
| 199 | − | |
| 200 | −h2 { | |
| 201 | − font-family: var(--serif); | |
| 202 | − font-size: 1.8rem; | |
| 203 | − font-weight: 500; | |
| 204 | − letter-spacing: -0.005em; | |
| 205 | − margin: 6px 0 16px; | |
| 206 | −} | |
| 207 | −h3 { font-family: var(--serif); font-weight: 500; margin: 12px 0 6px; } | |
| 208 | −h4 { font-weight: 600; margin: 0 0 6px; font-size: 0.92rem; } | |
| 209 | −.muted { color: var(--muted); } | |
| 210 | −.small { font-size: 0.8rem; } | |
| 211 | − | |
| 212 | −/* callout */ | |
| 213 | −.callout { | |
| 214 | − margin-top: 20px; | |
| 215 | − background: var(--accent-wash); | |
| 216 | − border: 1px solid #d9e2fa; | |
| 217 | − border-radius: 10px; | |
| 218 | − padding: 16px 20px; | |
| 219 | − font-size: 0.9rem; | |
| 220 | − color: var(--ink-soft); | |
| 221 | −} | |
| 222 | −.callout b { color: var(--ink); } | |
| 223 | − | |
| 224 | −/* conventions grid */ | |
| 225 | −.conv-grid { | |
| 226 | − display: grid; | |
| 227 | − grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); | |
| 228 | − gap: 16px; | |
| 229 | −} | |
| 230 | −.conv { | |
| 231 | − background: var(--card); | |
| 232 | − border: 1px solid var(--line); | |
| 233 | − border-radius: 12px; | |
| 234 | − padding: 18px 20px; | |
| 235 | − box-shadow: var(--shadow-sm); | |
| 236 | −} | |
| 237 | −.conv h4 { font-family: var(--serif); font-size: 1.05rem; font-weight: 500; } | |
| 238 | −.conv p { font-size: 0.85rem; color: var(--ink-soft); margin: 0; } | |
| 239 | − | |
| 240 | −/* coverage cards */ | |
| 241 | −.cards { | |
| 242 | − display: grid; | |
| 243 | − grid-template-columns: repeat(auto-fill, minmax(255px, 1fr)); | |
| 244 | − gap: 16px; | |
| 245 | − margin-bottom: 16px; | |
| 246 | −} | |
| 247 | −.card { | |
| 248 | − background: var(--card); | |
| 249 | − border: 1px solid var(--line); | |
| 250 | − border-radius: 12px; | |
| 251 | − padding: 18px 20px; | |
| 252 | − box-shadow: var(--shadow-sm); | |
| 253 | −} | |
| 254 | −.card.accent { | |
| 255 | − border-color: #c9d7f8; | |
| 256 | − background: linear-gradient(160deg, var(--accent-wash), #ffffff 70%); | |
| 257 | −} | |
| 258 | −.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } | |
| 259 | −.card-head h3 { margin: 0; font-size: 1.15rem; } | |
| 260 | −.card-ex { font-size: 0.66rem; color: var(--muted); } | |
| 261 | −.card p { font-size: 0.85rem; color: var(--ink-soft); margin: 8px 0 0; } | |
| 262 | −.card .small code { margin: 2px 3px 0 0; display: inline-block; } | |
| 263 | − | |
| 264 | −/* ---------------------------------------------------------------- endpoints */ | |
| 265 | − | |
| 266 | −.ep-group { margin-bottom: 40px; } | |
| 267 | −.ep-group > h2 { margin-bottom: 26px; } | |
| 268 | − | |
| 269 | −.endpoint { | |
| 270 | − display: flex; | |
| 271 | − background: var(--card); | |
| 272 | − border: 1px solid var(--line); | |
| 273 | − border-radius: 14px; | |
| 274 | − box-shadow: var(--shadow); | |
| 275 | − margin-bottom: 30px; | |
| 276 | − overflow: hidden; | |
| 277 | −} | |
| 278 | −.endpoint-index { | |
| 279 | − flex-shrink: 0; | |
| 280 | − width: 58px; | |
| 281 | − display: flex; | |
| 282 | − align-items: flex-start; | |
| 283 | − justify-content: center; | |
| 284 | − padding-top: 26px; | |
| 285 | − font-family: var(--serif); | |
| 286 | − font-style: italic; | |
| 287 | − font-size: 0.95rem; | |
| 288 | − color: var(--muted); | |
| 289 | − background: var(--bg-soft); | |
| 290 | − border-right: 1px solid var(--line); | |
| 291 | −} | |
| 292 | −.endpoint-body { padding: 22px 28px 26px; min-width: 0; flex: 1; } | |
| 293 | −.endpoint-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } | |
| 294 | −.method { | |
| 295 | − font-family: var(--mono); | |
| 296 | − font-size: 0.64rem; | |
| 297 | − font-weight: 600; | |
| 298 | − letter-spacing: 0.08em; | |
| 299 | − color: var(--accent-deep); | |
| 300 | − background: var(--accent-wash); | |
| 301 | − border: 1px solid #d9e2fa; | |
| 302 | − border-radius: 999px; | |
| 303 | − padding: 2px 11px; | |
| 304 | −} | |
| 305 | −.path { | |
| 306 | − font-size: 0.86rem; | |
| 307 | − background: var(--bg-soft); | |
| 308 | − white-space: normal; | |
| 309 | − word-break: break-all; | |
| 310 | − color: var(--ink); | |
| 311 | −} | |
| 312 | −.endpoint h3 { font-size: 1.35rem; margin: 14px 0 4px; } | |
| 313 | −.endpoint-desc { font-size: 0.9rem; color: var(--ink-soft); margin: 0 0 16px; } | |
| 314 | − | |
| 315 | −/* params table */ | |
| 316 | −table.params { | |
| 317 | − width: 100%; | |
| 318 | − border-collapse: collapse; | |
| 319 | − font-size: 0.82rem; | |
| 320 | − margin: 8px 0 20px; | |
| 321 | −} | |
| 322 | −table.params th, table.params td { | |
| 323 | − text-align: left; | |
| 324 | − padding: 9px 12px; | |
| 325 | − border-bottom: 1px solid var(--line); | |
| 326 | − vertical-align: top; | |
| 327 | −} | |
| 328 | −table.params thead th { | |
| 329 | − font-size: 0.64rem; | |
| 330 | − font-weight: 700; | |
| 331 | − text-transform: uppercase; | |
| 332 | − letter-spacing: 0.12em; | |
| 333 | − color: var(--muted); | |
| 334 | − border-bottom: 1px solid var(--line-strong); | |
| 335 | −} | |
| 336 | −table.params tr:last-child td { border-bottom: none; } | |
| 337 | −table.params td code { white-space: nowrap; } | |
| 338 | −td.type { font-family: var(--mono); font-size: 0.7rem; color: var(--muted); white-space: nowrap; } | |
| 339 | −td.num { font-family: var(--mono); font-size: 0.78rem; text-align: right; } | |
| 340 | −.req { | |
| 341 | − font-family: var(--mono); font-size: 0.6rem; font-weight: 600; | |
| 342 | − color: var(--accent-deep); background: var(--accent-wash); | |
| 343 | − border: 1px solid #d9e2fa; | |
| 344 | − border-radius: 999px; padding: 2px 8px; letter-spacing: 0.04em; | |
| 345 | −} | |
| 346 | −.opt { font-family: var(--mono); font-size: 0.6rem; color: var(--muted); } | |
| 347 | − | |
| 348 | −/* code tabs + response */ | |
| 349 | −.code-tabs, .response-block { margin-top: 16px; } | |
| 350 | −.code-tabs-bar { display: flex; gap: 5px; align-items: center; margin-bottom: 8px; } | |
| 351 | −.code-tabs-label { | |
| 352 | − font-size: 0.62rem; | |
| 353 | − font-weight: 700; | |
| 354 | − letter-spacing: 0.14em; | |
| 355 | − text-transform: uppercase; | |
| 356 | − color: var(--muted); | |
| 357 | − margin-right: 8px; | |
| 358 | −} | |
| 359 | −.tab { | |
| 360 | − font-family: var(--sans); | |
| 361 | − font-size: 0.74rem; | |
| 362 | − font-weight: 500; | |
| 363 | − background: transparent; | |
| 364 | − border: 1px solid var(--line); | |
| 365 | − border-radius: 999px; | |
| 366 | − color: var(--ink-soft); | |
| 367 | − padding: 4px 14px; | |
| 368 | − cursor: pointer; | |
| 369 | − transition: all 0.12s; | |
| 370 | −} | |
| 371 | −.tab:hover { border-color: var(--line-strong); background: var(--bg-soft); } | |
| 372 | −.tab.active { | |
| 373 | − background: var(--ink); | |
| 374 | − border-color: var(--ink); | |
| 375 | − color: #fff; | |
| 376 | −} | |
| 377 | −.tab.copy, .tab.live { margin-left: auto; } | |
| 378 | −.tab.live { | |
| 379 | − background: var(--accent); | |
| 380 | − border-color: var(--accent); | |
| 381 | − color: white; | |
| 382 | −} | |
| 383 | −.tab.live:hover { background: var(--accent-deep); } | |
| 384 | −.tab:disabled { opacity: 0.6; cursor: default; } | |
| 385 | −pre.response { max-height: 340px; overflow: auto; } | |
| 386 | − | |
| 387 | −/* ---------------------------------------------------------------- playground */ | |
| 388 | − | |
| 389 | −.playground { | |
| 390 | − margin-top: 18px; | |
| 391 | − border: 1px solid var(--line); | |
| 392 | − border-radius: 12px; | |
| 393 | − background: linear-gradient(180deg, var(--accent-wash) 0, var(--card) 90px); | |
| 394 | − padding: 16px 18px 18px; | |
| 395 | −} | |
| 396 | −.pg-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; } | |
| 397 | −.pg-title { | |
| 398 | − font-family: var(--serif); | |
| 399 | − font-style: italic; | |
| 400 | − font-size: 1.05rem; | |
| 401 | − color: var(--accent-deep); | |
| 402 | −} | |
| 403 | −.pg-hint { font-size: 0.72rem; color: var(--muted); } | |
| 404 | − | |
| 405 | −.pg-grid { | |
| 406 | − display: grid; | |
| 407 | − grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); | |
| 408 | − gap: 10px 12px; | |
| 409 | − margin-bottom: 14px; | |
| 410 | −} | |
| 411 | −.pg-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } | |
| 412 | −.pg-label { | |
| 413 | − font-family: var(--mono); | |
| 414 | − font-size: 0.68rem; | |
| 415 | − font-weight: 600; | |
| 416 | − color: var(--ink-soft); | |
| 417 | − display: flex; | |
| 418 | − align-items: baseline; | |
| 419 | − gap: 6px; | |
| 420 | −} | |
| 421 | −.pg-req { | |
| 422 | − font-style: normal; | |
| 423 | − font-size: 0.56rem; | |
| 424 | − color: var(--accent-deep); | |
| 425 | − background: var(--accent-wash); | |
| 426 | − border: 1px solid #d9e2fa; | |
| 427 | − border-radius: 999px; | |
| 428 | − padding: 0 6px; | |
| 429 | −} | |
| 430 | −.pg-in { margin-left: auto; font-size: 0.58rem; color: var(--muted); font-weight: 400; } | |
| 431 | −.pg-field input, .pg-field select { | |
| 432 | − font-family: var(--mono); | |
| 433 | − font-size: 0.78rem; | |
| 434 | − color: var(--ink); | |
| 435 | − background: var(--card); | |
| 436 | − border: 1px solid var(--line-strong); | |
| 437 | − border-radius: 8px; | |
| 438 | − padding: 7px 10px; | |
| 439 | − outline: none; | |
| 440 | − width: 100%; | |
| 441 | − transition: border-color 0.12s, box-shadow 0.12s; | |
| 442 | − -webkit-appearance: none; | |
| 443 | − appearance: none; | |
| 444 | −} | |
| 445 | −.pg-field select { | |
| 446 | − background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a8f98' fill='none' stroke-width='1.5'/%3E%3C/svg%3E"); | |
| 447 | − background-repeat: no-repeat; | |
| 448 | − background-position: right 10px center; | |
| 449 | − padding-right: 26px; | |
| 450 | −} | |
| 451 | −.pg-field input:focus, .pg-field select:focus { | |
| 452 | − border-color: var(--accent); | |
| 453 | − box-shadow: 0 0 0 3px var(--accent-wash); | |
| 454 | −} | |
| 455 | − | |
| 456 | −.pg-url { | |
| 457 | − display: flex; | |
| 458 | − align-items: center; | |
| 459 | − gap: 10px; | |
| 460 | − background: var(--card); | |
| 461 | − border: 1px solid var(--line); | |
| 462 | − border-radius: 8px; | |
| 463 | − padding: 8px 12px; | |
| 464 | − margin-bottom: 12px; | |
| 465 | − overflow-x: auto; | |
| 466 | −} | |
| 467 | −.pg-url-method { | |
| 468 | − font-family: var(--mono); | |
| 469 | − font-size: 0.62rem; | |
| 470 | − font-weight: 600; | |
| 471 | − color: var(--accent-deep); | |
| 472 | − letter-spacing: 0.06em; | |
| 473 | − flex-shrink: 0; | |
| 474 | −} | |
| 475 | −.pg-url code { | |
| 476 | − background: none; border: none; padding: 0; margin: 0; | |
| 477 | − font-size: 0.74rem; | |
| 478 | − white-space: nowrap; | |
| 479 | − color: var(--ink-soft); | |
| 480 | −} | |
| 481 | − | |
| 482 | −.tab.run { | |
| 483 | − background: var(--accent); | |
| 484 | − border-color: var(--accent); | |
| 485 | − color: white; | |
| 486 | − font-weight: 600; | |
| 487 | −} | |
| 488 | −.tab.run:hover { background: var(--accent-deep); } | |
| 489 | −.pg-spacer { margin-left: auto; } | |
| 490 | − | |
| 491 | −.pg-response { margin-top: 12px; } | |
| 492 | −.pg-resp-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } | |
| 493 | −.pg-status { | |
| 494 | − font-family: var(--mono); | |
| 495 | − font-size: 0.68rem; | |
| 496 | − font-weight: 600; | |
| 497 | − border-radius: 999px; | |
| 498 | − padding: 2px 10px; | |
| 499 | −} | |
| 500 | −.pg-status.ok { color: #116632; background: #e3f5ea; border: 1px solid #bfe6cd; } | |
| 501 | −.pg-status.err { color: #9e2a25; background: #fdeae9; border: 1px solid #f5c9c6; } | |
| 502 | −.pg-ms { font-family: var(--mono); font-size: 0.68rem; color: var(--muted); } | |
| 503 | −.pg-resp-label { | |
| 504 | − font-size: 0.62rem; | |
| 505 | − font-weight: 700; | |
| 506 | − letter-spacing: 0.14em; | |
| 507 | − text-transform: uppercase; | |
| 508 | − color: var(--muted); | |
| 509 | −} | |
| 510 | − | |
| 511 | −/* fields accordion */ | |
| 512 | −.fields { margin-top: 16px; } | |
| 513 | −.fields-toggle { | |
| 514 | − font-family: var(--sans); | |
| 515 | − font-size: 0.78rem; | |
| 516 | − font-weight: 600; | |
| 517 | − background: transparent; | |
| 518 | − border: 1px solid var(--line); | |
| 519 | − border-radius: 999px; | |
| 520 | − padding: 6px 16px; | |
| 521 | − cursor: pointer; | |
| 522 | − color: var(--ink-soft); | |
| 523 | − transition: all 0.12s; | |
| 524 | −} | |
| 525 | −.fields-toggle:hover { background: var(--accent-wash); border-color: #d9e2fa; color: var(--accent-deep); } | |
| 526 | −.fields-n { | |
| 527 | − font-family: var(--mono); | |
| 528 | − font-size: 0.62rem; | |
| 529 | − background: var(--accent-wash); | |
| 530 | − color: var(--accent-deep); | |
| 531 | − border-radius: 999px; | |
| 532 | − padding: 1px 8px; | |
| 533 | − margin-left: 6px; | |
| 534 | −} | |
| 535 | −.fields table.params { margin-top: 12px; } | |
| 536 | − | |
| 537 | −/* live status */ | |
| 538 | −.table-scroll { | |
| 539 | − max-height: 430px; | |
| 540 | − overflow-y: auto; | |
| 541 | − border: 1px solid var(--line); | |
| 542 | − border-radius: 12px; | |
| 543 | − box-shadow: var(--shadow-sm); | |
| 544 | − background: var(--card); | |
| 545 | − margin-top: 18px; | |
| 546 | −} | |
| 547 | −.table-scroll table.params { margin: 0; } | |
| 548 | −.table-scroll thead th { position: sticky; top: 0; background: var(--card); } | |
| 549 | −#live .stat-chips { margin-top: 8px; } | |
| 550 | − | |
| 551 | −/* about + footer */ | |
| 552 | −.about p { max-width: 640px; } | |
| 553 | −footer { | |
| 554 | − border-top: 1px solid var(--line); | |
| 555 | − padding-top: 24px; | |
| 556 | − color: var(--muted); | |
| 557 | − font-size: 0.8rem; | |
| 558 | − display: flex; | |
| 559 | − justify-content: space-between; | |
| 560 | − align-items: baseline; | |
| 561 | − gap: 16px; | |
| 562 | − flex-wrap: wrap; | |
| 563 | −} | |
| 564 | −.foot-brand { | |
| 565 | − font-family: var(--serif); | |
| 566 | − font-style: italic; | |
| 567 | − letter-spacing: 0.02em; | |
| 568 | − font-size: 0.85rem; | |
| 569 | − color: var(--ink-soft); | |
| 570 | − text-transform: none; | |
| 571 | −} | |
| 572 | − | |
| 573 | −/* ---------------------------------------------------------------- responsive */ | |
| 574 | − | |
| 575 | −@media (max-width: 860px) { | |
| 576 | − .sidebar { display: none; } | |
| 577 | − main { padding: 30px 22px 50px; } | |
| 578 | − .endpoint-index { display: none; } | |
| 579 | − .hero h1 { font-size: 2.5rem; } | |
| 580 | − .chip { padding-right: 18px; } | |
| 581 | −} | |
| 582 | ||