diff --git a/.github/workflows/test_website.yml b/.github/workflows/test_website.yml new file mode 100644 index 00000000..956c9b24 --- /dev/null +++ b/.github/workflows/test_website.yml @@ -0,0 +1,49 @@ +name: test / website + +on: + push: + branches: [main] + paths: + - 'site/www/**' + - 'site/tests/**' + - '.github/workflows/test_website.yml' + - 'package.json' + - 'package-lock.json' + pull_request: + branches: [main] + paths: + - 'site/www/**' + - 'site/tests/**' + - '.github/workflows/test_website.yml' + - 'package.json' + - 'package-lock.json' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + website: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium firefox webkit + - run: npx playwright test --config site/tests/playwright.config.ts + - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: website-browser-report + path: | + playwright-report/website + test-results + retention-days: 14 diff --git a/site/tests/playwright.config.ts b/site/tests/playwright.config.ts new file mode 100644 index 00000000..98308dd3 --- /dev/null +++ b/site/tests/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; +import { resolve } from 'node:path'; + +export default defineConfig({ + testDir: '.', + testMatch: 'website.spec.ts', + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + outputDir: resolve(__dirname, '../../test-results/website'), + reporter: [['list'], ['html', { + outputFolder: resolve(__dirname, '../../playwright-report/website'), + open: 'never', + }]], + use: { + baseURL: process.env.WEBSITE_URL || 'http://127.0.0.1:4186', + reducedMotion: 'reduce', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: process.env.WEBSITE_URL ? undefined : { + command: 'node site/tests/server.mjs', + cwd: resolve(__dirname, '../..'), + url: 'http://127.0.0.1:4186', + reuseExistingServer: false, + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ], +}); diff --git a/site/tests/server.mjs b/site/tests/server.mjs new file mode 100644 index 00000000..35be91c7 --- /dev/null +++ b/site/tests/server.mjs @@ -0,0 +1,25 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; + +// Serve only website assets used by the tests, never the checkout. +const assets = new Map([ + ['/', ['index.html', 'text/html; charset=utf-8']], + ['/site.css', ['site.css', 'text/css; charset=utf-8']], + ['/assets/inter-latin-variable.woff2', ['assets/inter-latin-variable.woff2', 'font/woff2']], + ['/askable-ui-code.mp4', ['askable-ui-code.mp4', 'video/mp4']], +]); + +createServer(async (request, response) => { + const asset = assets.get(new URL(request.url, 'http://localhost').pathname); + if (!asset || !['GET', 'HEAD'].includes(request.method)) { + response.writeHead(404).end(); + return; + } + try { + const body = await readFile(new URL(`../www/${asset[0]}`, import.meta.url)); + response.writeHead(200, { 'content-type': asset[1], 'content-length': body.length }); + response.end(request.method === 'HEAD' ? undefined : body); + } catch { + response.writeHead(500).end(); + } +}).listen(4186, '127.0.0.1'); diff --git a/site/tests/website.spec.ts b/site/tests/website.spec.ts new file mode 100644 index 00000000..ddbb5764 --- /dev/null +++ b/site/tests/website.spec.ts @@ -0,0 +1,192 @@ +import { test, expect, type Page } from '@playwright/test'; + +async function settleRendering(page: Page) { + await page.evaluate(async () => { + await document.fonts.ready; + // Let font metrics and inherited colors reach the compositor after a resize or reload. + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + }); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await settleRendering(page); +}); + +for (const width of [320, 390, 768, 1280, 1440]) { + test(`layout fits ${width}px and keeps the first action visible`, async ({ page }) => { + await page.setViewportSize({ width, height: width < 768 ? 844 : 720 }); + await settleRendering(page); + await expect(page.locator('#kpi-grid .kpi-card')).toHaveCount(4); + await expect(page.locator('#install-copy')).toBeInViewport(); + await expect(page.getByRole('heading', { name: 'Analytics workspace' })).toBeInViewport(); + await expect(page.locator('.developer-panel')).not.toHaveAttribute('open'); + await expect(page.locator('video')).not.toHaveAttribute('autoplay'); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + await page.screenshot({ path: test.info().outputPath(`homepage-${width}.png`) }); + await page.getByText('Developer view', { exact: true }).click(); + await expect(page.getByRole('button', { name: 'JSON', exact: true })).toBeVisible(); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ path: test.info().outputPath(`website-${width}.png`), fullPage: true }); + }); +} + +test('mobile menu supports keyboard, Escape, anchor navigation and outside clicks', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const menu = page.locator('#nav-menu'); + const summary = menu.locator('summary'); + await summary.focus(); + await page.keyboard.press('Enter'); + await expect(menu).toHaveAttribute('open'); + await expect(menu.getByRole('link', { name: 'Docs', exact: true })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(menu).not.toHaveAttribute('open'); + await expect(summary).toBeFocused(); + await summary.click(); + await menu.getByRole('link', { name: 'MCP', exact: true }).click(); + await expect(page).toHaveURL(/#mcp$/); + await expect(menu).not.toHaveAttribute('open'); + await summary.click(); + await page.getByRole('heading', { name: 'Connect the assistant you already use.' }).click(); + await expect(menu).not.toHaveAttribute('open'); + await summary.click(); + await page.setViewportSize({ width: 1280, height: 720 }); + await expect(menu).not.toHaveAttribute('open'); +}); + +test('selected context can be reviewed, formatted, sent and removed', async ({ page }) => { + await page.locator('.kpi-card').first().click(); + await page.getByText('Review context', { exact: true }).click(); + await expect(page.locator('#context-chip')).toContainText('$128,400'); + await expect(page.locator('#context-chip')).not.toContainText('Ask AI'); + await page.getByText('Developer view', { exact: true }).click(); + await page.getByRole('button', { name: 'JSON', exact: true }).click(); + await expect(page.getByRole('button', { name: 'JSON', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('button', { name: 'Natural', exact: true })).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('#code-ctx')).toContainText('"widget": "revenue"'); + await page.getByRole('button', { name: 'Meta only', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Meta only', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('button', { name: 'Text + meta', exact: true })).toHaveAttribute('aria-pressed', 'false'); + expect(JSON.parse(await page.locator('#code-ctx').innerText())).not.toHaveProperty('text'); + await page.getByLabel('Question about selected context').fill('Why is revenue growing?'); + await page.getByRole('button', { name: 'Send question', exact: true }).click(); + await expect(page.getByRole('log', { name: 'Demo conversation' })).toContainText('MRR grew 12.4%'); + await page.getByRole('button', { name: 'Remove selected context', exact: true }).click(); + await expect(page.locator('#chat-context-bar')).toBeHidden(); + await expect(page.locator('#chat-input')).toBeEmpty(); +}); + +for (const shape of ['region', 'square', 'circle', 'lasso']) { + test(`${shape} captures content and keeps the mode selected`, async ({ page }) => { + const tool = page.locator(`[data-tool="${shape}"]`); + await tool.click(); + await expect(tool).toHaveAttribute('aria-pressed', 'true'); + await page.locator('.kpi-card').first().scrollIntoViewIfNeeded(); + const box = await page.locator('.kpi-card').first().boundingBox(); + if (!box) throw new Error('Missing KPI bounds'); + const x = box.x + 8, y = box.y + 8; + await page.mouse.move(x, y); + await page.mouse.down(); + if (shape === 'lasso') { + await page.mouse.move(x + box.width - 16, y, { steps: 6 }); + await page.mouse.move(x + box.width - 16, y + box.height - 16, { steps: 6 }); + await page.mouse.move(x, y + box.height - 16, { steps: 6 }); + await page.mouse.move(x, y, { steps: 6 }); + } else { + await page.mouse.move(x + box.width - 16, y + box.height - 16, { steps: 10 }); + } + await page.mouse.up(); + await expect(page.locator('#selected-region-preview')).toContainText('Monthly Recurring Revenue'); + await expect(page.locator('#context-chip')).toContainText('$128,400'); + await expect(page.locator('#context-chip')).not.toContainText('Ask AI'); + await expect(tool).toHaveAttribute('aria-pressed', 'true'); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.locator('[data-mode="click"]')).toHaveAttribute('aria-pressed', 'true'); + }); +} + +test('text mode ignores plain clicks and retains captured text', async ({ page }) => { + await page.locator('#send-selection').click(); + await page.locator('.kpi-card').first().click(); + await expect(page.locator('#chat-context-bar')).toBeHidden(); + await page.locator('.kpi-label').first().evaluate(element => { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + await expect(page.locator('#selected-text-preview')).toHaveText(/^Monthly Revenue$/i); + await page.getByLabel('Question about selected context').focus(); + await expect(page.locator('.text-capture-mark').first()).toBeVisible(); +}); + +test('homepage and selected context screenshots use the website font', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1100 }); + await page.reload(); + await settleRendering(page); + await expect.poll(() => page.evaluate(() => document.fonts.check('400 16px Inter'))).toBe(true); + await page.screenshot({ path: test.info().outputPath('homepage-desktop.png') }); + await page.locator('.kpi-card').first().click(); + await page.getByText('Review context', { exact: true }).click(); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ path: test.info().outputPath('homepage-context.png') }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + await settleRendering(page); + await page.screenshot({ path: test.info().outputPath('homepage-mobile.png') }); +}); + +test('integration tabs support mouse and keyboard navigation', async ({ page }) => { + const tabs = page.getByRole('tablist', { name: 'Integration steps' }); + await tabs.getByRole('tab', { name: 'Observe', exact: true }).click(); + await expect(page.getByRole('tabpanel')).toContainText('createAskableContext'); + await page.getByRole('tab', { name: 'Observe', exact: true }).press('ArrowRight'); + await expect(page.getByRole('tab', { name: 'Send', exact: true })).toBeFocused(); + await expect(page.getByRole('tabpanel')).toContainText('toAgentRequest'); + await page.getByRole('tab', { name: 'Send', exact: true }).press('Home'); + await expect(page.getByRole('tab', { name: 'Annotate', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tabpanel')).toContainText('data-askable'); +}); + +test('font is local and inline icons retain accessible colors', async ({ page }) => { + await expect.poll(() => page.evaluate(() => document.fonts.check('400 16px Inter'))).toBe(true); + await expect(page.locator('.pattern-option .icon')).toHaveCount(8); + await expect(page.locator('.icon use')).toHaveCount(0); + expect(await page.locator('.pattern-option .icon path').count()).toBeGreaterThan(0); + await expect(page.locator('#chat-send .icon')).toHaveCSS('stroke', 'rgb(255, 255, 255)'); + await expect(page.locator('[data-mode="click"]')).toHaveCSS('color', 'rgb(102, 80, 204)'); + await expect(page.locator('link[href*="fonts.googleapis.com"]')).toHaveCount(0); +}); + +test('selection mode controls do not resize the workspace', async ({ page }) => { + for (const width of [390, 1280]) { + await page.setViewportSize({ width, height: 900 }); + await settleRendering(page); + const before = await page.locator('.demo-controls').boundingBox(); + await page.locator('[data-tool="lasso"]').click(); + const after = await page.locator('.demo-controls').boundingBox(); + if (!before || !after) throw new Error('Missing toolbar bounds'); + // Firefox can round bounding boxes differently after the automatic scroll. + expect(after.height).toBeCloseTo(before.height, 2); + await expect(page.locator('#cancel-tool')).toBeVisible(); + await expect(page.locator('.workspace-meta')).toBeHidden(); + await settleRendering(page); + await page.screenshot({ path: test.info().outputPath(`lasso-controls-${width}.png`) }); + await page.locator('#cancel-tool').click(); + await expect(page.locator('.workspace-meta')).toBeVisible(); + } +}); + +test('current capabilities and release boundaries are explicit', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Explore Bridge' })).toHaveAttribute('href', /guide\/bridge$/); + await expect(page.locator('.release-note')).toContainText('not yet released to npm'); + await expect(page.locator('main')).not.toContainText('v0.15.0'); + const duplicateIds = await page.locator('[id]').evaluateAll(elements => { + const ids = elements.map(element => element.id); + return ids.filter((id, index) => ids.indexOf(id) !== index); + }); + expect(duplicateIds).toEqual([]); +}); diff --git a/site/www/assets/INTER-LICENSE.txt b/site/www/assets/INTER-LICENSE.txt new file mode 100644 index 00000000..40589daa --- /dev/null +++ b/site/www/assets/INTER-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/site/www/assets/LUCIDE-LICENSE.txt b/site/www/assets/LUCIDE-LICENSE.txt new file mode 100644 index 00000000..718bb3f0 --- /dev/null +++ b/site/www/assets/LUCIDE-LICENSE.txt @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/site/www/assets/README.md b/site/www/assets/README.md new file mode 100644 index 00000000..3398ca49 --- /dev/null +++ b/site/www/assets/README.md @@ -0,0 +1,6 @@ +# Website Assets + +- `inter-latin-variable.woff2`: Inter Latin variable font, distributed by `@fontsource-variable/inter` 5.3.0. Unmodified, licensed under SIL Open Font License 1.1. See `INTER-LICENSE.txt`. +- Icons in `index.html`: a subset of Lucide 1.48.0, generated from `lucide-static` icon nodes and inlined for consistent Safari color inheritance. Licensed under ISC with upstream MIT notices. See `LUCIDE-LICENSE.txt`. + +These are local website dependencies, not promotional assets. Keeping them local avoids third-party font and icon requests on page load. diff --git a/site/www/assets/inter-latin-variable.woff2 b/site/www/assets/inter-latin-variable.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/site/www/assets/inter-latin-variable.woff2 differ diff --git a/site/www/index.html b/site/www/index.html index f7e8ebc1..a8208265 100644 --- a/site/www/index.html +++ b/site/www/index.html @@ -3,853 +3,36 @@ - askable-ui — Your LLM doesn't know what the user is looking at. Fix that. - + askable-ui: UI context for AI assistants + - - + + - - + + - - - + + - - - +
-
+
-
✦ Claude Desktop · Cursor · CopilotKit · any LLM
-

Your model knows what the user means.

-

- Your AI copilot can't tell which metric the user is looking at, which row triggered the question, - or what they highlighted. askable-ui fixes that with one attribute — no screenshots, no stale system - prompts, no guessing. The AI gets the exact data the user sees, updated on every interaction. -

-
- Click - Ask AI button - Region - Square - Circle - Lasso - Selected text -
+

UI context for AI.

+

Let people select what matters. Give their assistant the context to understand it.

- - See it live - Documentation - - - -
-
- Element + visual context - Zero framework lock-in - MCP-ready packets - React · Vue · Svelte · Vanilla JS -
-
- -
-
-
-
-
live focus lens
-
-
-
-
-
-
-
MRR
-
$128,400
-
+12.4% this month
-
-
-
Churn
-
3.2%
-
improving
-
-
-
NPS
-
67
-
industry avg 44
-
-
-
Upsell
-
Umbrella
-
starter → business
-
-
-
-
Acme$8.4khealthy
-
Globex$5.2kat risk
-
Umbrella$2.1kexpanding
-
-
-
-
The assistant can read the element, area, or text the user explicitly selected.
-
What should I do about this circled account?
-
Globex is at risk. No login in 14 days, $5.2k MRR exposed.
-
-
-
<tr data-askable='{"company":"Globex Inc","status":"at_risk","last_login":"14 days ago"}'> - ... -</tr> - -User is focused on: company: Globex Inc, status: at_risk, last_login: 14 days ago
-
-
-
-
- -
- -
- -
- -

Not every question starts with a click.

-

Askable gives users more ways to say “this is what I mean” without sending the entire page to the model.

-
-
-
-

Context becomes an intentional user action.

-

Use DOM focus for the common path, then add explicit tools for visual ambiguity: draw over a chart, square off a fixed area, circle an outlier, lasso an irregular area, or send the text the user highlighted.

-
-
region.start({ shape: 'lasso' });
-text.captureNow();
-ctx.push(meta, text);
-
-
-
-
↖
-

Element focus

-

Click, hover, keyboard focus, and Ask AI buttons bind context to existing widgets.

-
-
-
□
-

Region capture

-

Drag a rectangular page area when the answer depends on a visible section.

-
-
-
▣
-

Square capture

-

Constrain the selected area when the target should keep an equal width and height.

-
-
-
○
-

Circle capture

-

Circle an anomaly, data point, product defect, chart spike, or object on screen.

-
-
-
⌁
-

Lasso capture

-

Freehand select irregular content that does not fit cleanly into a box.

-
-
-
T
-

Selected text

-

Send highlighted copy without asking the model to infer which words matter.

-
-
-
◇
-

Context packets

-

Move structured context through MCP bridges, browser surfaces, and agent runtimes.

-
-
-
-
-
No page scraping
-

Context follows intent, not the whole DOM

-

Other approaches serialize the entire page or guess what matters. askable captures exactly what the user marked: an element, area, shape, or text selection.

-
-
-
App-owned sources
-

Use real state, not just visible DOM

-

Register redacted, timeout-safe resolvers for paginated tables, documents, maps, charts, calendars, or custom state so selected context resolves the right backing data.

-
-
-
Any stack
-

Dashboards, forms, tables, support tools

-

Works anywhere a user points, clicks, draws, circles, lassos, highlights, or hovers. React, Vue, Svelte, or plain HTML.

-
-
-
-
- -

Select the UI. Watch the prompt tighten.

-

Click a KPI, draw a region, circle a row, lasso a panel, or highlight text. The context panel updates in real time.

-
+
+

Analytics workspace

+ Sample data +
Interaction pattern Click
-
- - - - - - - -
@@ -1091,18 +114,7 @@

Select the UI. Watch the prompt tighten.

-
- Format: -
- - -
- Prompt text: -
- - -
-
+
@@ -1118,7 +130,7 @@

Select the UI. Watch the prompt tighten.

-
Top Accounts same data drives UI + AI context
+
Accounts Monthly recurring revenue
@@ -1128,21 +140,21 @@

Select the UI. Watch the prompt tighten.

+
+ Developer view +
+ Format: +
+ + +
+ Prompt text: +
+ + +
+
@@ -1182,331 +209,182 @@

Select the UI. Watch the prompt tighten.

waiting for focus…
+
+ + + +
+

More than what meets the eye.

+

A selection is the start. Connect the data behind it and choose where it goes.

+
+
+ +

Beyond the visible rows

+

Include full datasets, documents, and app state. You choose the backing data, even when it is not on screen.

+ Connect your data +
+
+ +

Bring your own chat

+

Deliver selected context to your chat, browser extension, iframe, or server through Bridge.

+ Explore Bridge +
+
+ +

Review before sending

+

Send the exact snapshot someone reviewed, even if their selection changes before delivery.

+

The reviewed-request React API is on main, not yet released to npm.

+ Review the React guide +
- -

Three simple steps.

-

No giant framework. No invasive serialization. Let the model borrow the user's explicit context.

-
-
-
1
-

Annotate or capture

-

Add data-askable wherever your UI already has meaning, then add tools for regions, circles, lasso selections, and highlighted text.

-
<div data-askable='{"metric":"MRR","value":"$128K"}'>
-  ...
+      
+

A small addition.
A shared understanding.

+

Keep your components, your data, and your model. Add context where your UI already has meaning.

+ Explore the integration +
+
+
+ + + +
+
+

Structured data, attached to the element it describes.

+
<div
+  data-askable='{
+    "metric": "Monthly revenue",
+    "value": 128400,
+    "change": "+12.4%"
+  }'
+>
+  $128,400
 </div>
-
-
-
2
-

Observe intent

-

Call askable.observe(document) once. askable tracks click, hover, focus, explicit selection, and semantic app events.

-
askable.observe(document, {
+        
+ +
+
+ Watch the integration walkthrough + +
+
- -

Pick your stack. Keep the idea.

-

One core library. Thin adapters for every framework. Same mental model everywhere.

+

At home in your stack.

+

One core, with adapters for the way you build.

JS · Core
@askable-ui/core
Framework-agnostic observer and context builder. Zero dependencies.
- npm +
JS · React
@askable-ui/react
-
45+ hooks — useAskableCartSource(), useAskableMultistepSource(), useAskableFormSource(), useAskableTableSource(), useAskableTimeSource(), useAskableThemeSource() and more. <Askable> for React 17+.
- npm +
Components and hooks for selection, app data, and chat. React 17+.
+
JS · Vue
@askable-ui/vue
-
45+ composables — useAskableCartSource(), useAskableMultistepSource(), useAskableFormSource(), useAskableThemeSource(), useAskableLocaleSource() and more. <Askable> for Vue 3.
- npm +
Reactive context and app-owned sources for Vue 3.
+
JS · Svelte
@askable-ui/svelte
-
Svelte 4 stores and Svelte 5 runes — 45+ sources including useAskableCartSource(), useAskableMultistepSource(), useAskableScrollSource(), useAskableWindowSource() and more.
- npm +
Svelte 4 stores and Svelte 5 runes for reactive UI context.
+
JS · SolidJS
@askable-ui/solid
-
45+ reactive primitives for SolidJS — useAskableCartSource(), useAskableMultistepSource(), useAskableNetworkSource(), useAskableFocusSource() and more. <Askable> for SolidJS.
- npm +
Fine-grained reactive context and source primitives for SolidJS.
+
TS · Angular
@askable-ui/angular
-
Injectable services for Angular 16+ — AskableCartSourceService, AskableMultistepSourceService, AskableFormSourceService, AskableTableSourceService, AskableTimeSourceService and more.
- npm +
Injectable services and directives for Angular 16+.
+
TS · Qwik
@askable-ui/qwik
-
useAskable() and <Askable> for Qwik City apps — resumable, zero-JS-by-default.
- npm +
useAskable() and <Askable> for Qwik City apps: resumable, zero-JS-by-default.
+
HTML · Any framework
@askable-ui/web-component
-
<askable-context> custom element — zero build step, works in HTMX, Ember, or plain HTML via CDN.
- npm +
<askable-context> custom element: zero build step, works in HTMX, Ember, or plain HTML via CDN.
+ +
+ +
Transport
+
@askable-ui/bridge
+
Deliver context to a chat UI, extension, iframe, or HTTP endpoint.
+
-
-
More coming
-
Your framework?
-
The core is framework-agnostic. Writing an adapter is ~30 lines. PRs welcome.
- coming soon -
-
-
- -
- -

Plugs into your AI layer.

-

toPromptContext() returns a plain string. Pass it to any LLM framework or API — no adapter needed.

-
-
-
MCP client
-
Claude Desktop
-
Claude sees exactly what the user sees. Ask about the focused element without screenshots.
-
-
-
MCP client
-
Cursor
-
Your IDE agent reads live UI context via the askable MCP server. Accurate, no manual description.
-
-
-
Framework
-
CopilotKit
-
Pass context via useCopilotReadable(). The LLM sees what the user selected.
-
-
-
Framework
-
Vercel AI SDK
-
Inject into the system message or tool context for streaming chat UIs.
-
-
-
Any LLM API
-
OpenAI · Anthropic
-
Append the string to your system or user message. Works with any provider.
-
-
- -
-
-
- -

Put Askable context on the page, then let MCP clients read it.

-

WebMCP is the browser-local path: a page widget connects to a local MCP server and exposes approved page tools, prompts, and resources. Hosted MCP is the separate HTTPS endpoint path for remote MCP clients.

-
-
page widget ↔ local MCP client
-
-
-
-

WebMCP page

-

Add a page-side bridge that exposes the current Askable packet, prompt text, and approved app sources as page-owned context.

-
createAskableMcpPageBridge({
-  provider,
-  allowedOrigins: [
-    window.location.origin
-  ]
-});
-
-
-

Local client

-

The user runs a local WebMCP MCP server in their client, creates a token, then connects the page widget with that token.

-
{
-  "mcpServers": {
-    "webmcp": {
-      "command": "npx",
-      "args": [
-        "-y",
-        "@jason.today/webmcp@latest",
-        "--mcp"
-      ]
-    }
-  }
-}
-
-
-

Page resources

-

Askable can provide the selected element, lassoed region, highlighted text, viewport, or app-owned source as an askable://current page resource.

-
window.postMessage({
-  type: "read_current_resource",
-  options: {
-    resource: { uri: "askable://current" }
-  }
-});
-
-
-

Hosted MCP

-

For server-side use, expose the same provider through a secure HTTPS MCP route with auth, CORS, request limits, and tenancy checks.

-
createAskableMcpWebHandler({
-  authorize,
-  provider,
-  maxRequestBodyBytes: 256 * 1024
-});
-
-
- -

Give Claude Desktop eyes into your app.

-

Expose your app's live UI context as an MCP server. Claude, Cursor, and any MCP client can call get_current_context to see exactly what the user is looking at — no screenshots, no description needed.

-
-
-
-

Three lines to an MCP server.

-

Any page annotated with data-askable instantly becomes queryable by AI agents. The MCP bridge translates live DOM state into structured Context packets that agents can read and reason about.

-
-
import { createAskableMcpServer } from '@askable-ui/mcp';
-
-const server = createAskableMcpServer({ provider });
-server.connect(transport); // stdio, SSE, or WebSocket
+

Connect the assistant you already use.

+

Choose how selected context reaches your assistant. Each connection needs its own setup and permissions.

+
+
+

In-app chat

+

Pass prompt context to your model or use Bridge to deliver a packet to your existing chat UI.

+ AI SDK examples
-
-
-
↗
-

get_current_context

-

Returns the focused element's structured metadata, surrounding context, and interaction history as a typed JSON packet.

-
-
-
T
-

format_context_for_prompt

-

Returns a prompt-ready text rendering of the current context — drop it directly into any system message.

-
-
-
🔒
-

Privacy gate built in

-

Set requireRedacted: true to block unredacted packets from leaving the page. PII stays in your app.

-
-
-
◇
-

Schema resource

-

The MCP server exposes the full Context packet JSON Schema so agents can self-document and validate responses.

-
-
-
-
-

claude_desktop_config.json

-
{
-  "mcpServers": {
-    "my-app": {
-      "command": "node",
-      "args": ["./mcp-server.js"]
-    }
-  }
-}
-

→ Full MCP integration guide

-
-
- -
- -

Screenshots don't scale. Descriptions go stale.

-

Every other approach to giving AI context about your UI breaks down under real conditions.

-
-
-
📸
-

Vision / screenshots

-
    -
  • Expensive per token, slow to generate
  • -
  • Fails on dense data tables and charts
  • -
  • Leaks PII to the model if not scrubbed
  • -
  • Can't read dynamic or lazy-loaded data
  • -
-
-
-
📝
-

Hardcoded system prompts

-
    -
  • Describe the page, not the user's current state
  • -
  • Go stale the moment the data changes
  • -
  • Burn context window on irrelevant sections
  • -
  • No awareness of what the user selected or clicked
  • -
+
+

Browser companion

+

Capture page context through the extension example, or connect a page-side bridge to a local MCP companion.

+ Browser extension example
-
-
✦
-

askable-ui

-
    -
  • Structured JSON — no vision model needed
  • -
  • Updates on every click, focus, or scroll event
  • -
  • Privacy-gated before leaving the page
  • -
  • Works with any LLM, framework, or MCP client
  • -
+
+

MCP clients

+

Expose approved context through an MCP server for clients such as Claude Desktop or Cursor. Configure the provider, transport, and authorization.

+ MCP integration guide
-
- -
- -

Every layer of your app. Zero prompt engineering.

-

45+ built-in sources cover UI state, user session, environment, and device — automatically composed into a single context string the AI can reason about.

-
-
-
New in v0.15.0
-
Cart source
-
useAskableCartSource() — items, quantities, subtotal, tax, shipping, coupon. Mutate with addItem, setTotals, clearCart. Works in React, Vue, Svelte, SolidJS, and Angular.
-
-
-
New in v0.15.0
-
Multistep source
-
useAskableMultistepSource() — step name, index, progress %, isComplete. Navigate with next, prev, goTo. Works in all frameworks.
-
-
-
UI & content
-
Forms, tables, search
-
useAskableFormSource(), useAskableTableSource(), useAskableSearchSource(), useAskableTabSource(), useAskableMediaSource().
-
-
-
Environment
-
Device & browser state
-
useAskableThemeSource(), useAskableWindowSource(), useAskableLocaleSource(), useAskableNetworkSource(), useAskablePermissionSource(), useAskableConnectionSource().
-
-
-
Sensors & time
-
Time, location, battery
-
useAskableTimeSource() with business hours config, useAskableGeolocationSource(), useAskableBatterySource(), useAskablePerformanceSource() with Core Web Vitals.
-
-
-
User behaviour
-
Session intelligence
-
useAskableAnalyticsSource(), useAskableAbTestSource(), useAskableFeatureFlagSource(), useAskableIdleSource(), useAskableFocusSource(), useAskableScrollSource().
-
-
-

All sources are composable — read the full API reference →

+

You control what leaves your app. Sanitize sensitive data before sharing it. The redaction gate checks the packet's redaction flag; it does not detect personal information for you.

-

Your AI. Your UI. Finally connected.

-

Drop data-askable on your most important element. Working context in under five minutes — or scaffold a full copilot app in one command.

+

Start with one element.

+

Add meaningful context to the interface you already have.

Read the docs Live demo ↗ @@ -1520,7 +398,7 @@

Your AI. Your UI. Finally connected.

- askable-ui — context that feels native to the UI + askable-ui · Open source. MIT licensed.
Docs MCP guide @@ -1639,7 +517,7 @@

Your AI. Your UI. Finally connected.

var p = ['User is focused on:']; if (ms) p.push(ms); if (inclText && f.text) p.push('value "' + f.text + '"'); - return p.join(' — '); + return p.join(': '); }; var kpiData = [ { widget: 'revenue', metric: 'Monthly Recurring Revenue', value: '$128,400', change: '+12.4%', period: 'last 30 days', label: 'Monthly Revenue', changeDir: 'up', changeText: '+12.4% vs last month' }, @@ -1663,7 +541,7 @@

Your AI. Your UI. Finally connected.

var card = document.createElement('div'); card.className = 'kpi-card'; card.tabIndex = 0; card.setAttribute('data-askable', JSON.stringify(meta)); - card.innerHTML = '' + + card.innerHTML = '' + '
' + d.label + '
' + '
' + d.value + '
' + '
' + d.changeText + '
'; @@ -1679,7 +557,7 @@

Your AI. Your UI. Finally connected.

tr.setAttribute('data-askable', JSON.stringify(meta)); tr.innerHTML = '
' + '' + - ''; + ''; tbody.appendChild(tr); }); var askable = new Ctx(); @@ -1739,8 +617,10 @@

Your AI. Your UI. Finally connected.

document.querySelectorAll('[data-format]').forEach(function(btn) { btn.addEventListener('click', function(e) { e.stopPropagation(); - document.querySelectorAll('[data-format]').forEach(function(b) { b.classList.remove('active'); }); - btn.classList.add('active'); + document.querySelectorAll('[data-format]').forEach(function(b) { + b.classList.toggle('active', b === btn); + b.setAttribute('aria-pressed', String(b === btn)); + }); promptOpts.format = btn.getAttribute('data-format'); refreshCtxDisplay(); }); @@ -1748,8 +628,10 @@

Your AI. Your UI. Finally connected.

document.querySelectorAll('[data-text]').forEach(function(btn) { btn.addEventListener('click', function(e) { e.stopPropagation(); - document.querySelectorAll('[data-text]').forEach(function(b) { b.classList.remove('active'); }); - btn.classList.add('active'); + document.querySelectorAll('[data-text]').forEach(function(b) { + b.classList.toggle('active', b === btn); + b.setAttribute('aria-pressed', String(b === btn)); + }); promptOpts.includeText = btn.getAttribute('data-text') === 'on'; refreshCtxDisplay(); }); @@ -1760,10 +642,14 @@

Your AI. Your UI. Finally connected.

} function setPatternState(btn, hint) { if (!btn) return; + document.querySelectorAll('.pattern-option').forEach(function(option) { + option.setAttribute('aria-pressed', String(option === btn)); + }); if (activePatternName) activePatternName.textContent = btn.getAttribute('data-pattern-title') || btn.textContent.trim(); if (patternPayload) patternPayload.textContent = btn.getAttribute('data-pattern-payload') || 'Context packet'; if (hint) setToolHint(hint); } + setPatternState(document.querySelector('.pattern-option.active')); function resizeAiTrail() { if (!aiCursorCanvas || !selectionLayer || !aiCursorCtx) return; var rect = selectionLayer.getBoundingClientRect(); @@ -2255,6 +1141,8 @@

Your AI. Your UI. Finally connected.

}; function scrollBottom() { messagesEl.scrollTop = messagesEl.scrollHeight; } function addMsg(type, text) { + var empty = document.getElementById('chat-empty'); + if (empty) empty.remove(); var wrap = document.createElement('div'); wrap.className = 'msg ' + type; var bubble = document.createElement('div'); bubble.className = 'msg-bubble'; bubble.textContent = text; wrap.appendChild(bubble); @@ -2426,7 +1314,7 @@

Your AI. Your UI. Finally connected.

} codeAttr.classList.remove('empty'); codeCtx.textContent = prompt; codeCtx.classList.remove('empty'); - // Show context bar — let user see what's loaded before sending + // Show context bar: let user see what's loaded before sending contextBar.style.display = 'flex'; contextChip.textContent = prompt; if (meta && meta.capture === 'text-selection') { @@ -2491,8 +1379,8 @@

Your AI. Your UI. Finally connected.

reply = 'I will answer from the selected text you attached. In a real chat request this text is sent as explicit context beside your question, so the model does not need to infer which copy on the page matters.'; } else if (w === 'revenue') { if (lower.indexOf('trend') !== -1 || lower.indexOf('why') !== -1 || lower.indexOf('driv') !== -1) - reply = 'MRR grew 12.4% — likely a mix of new expansion + retention improving. Check if seat upgrades or plan changes account for the delta.'; - else reply = 'MRR is $128,400 — up 12.4%. Momentum looks healthy with expansion and retention both moving in the right direction.'; + reply = 'MRR grew 12.4%: likely a mix of new expansion + retention improving. Check if seat upgrades or plan changes account for the delta.'; + else reply = 'MRR is $128,400: up 12.4%. Momentum looks healthy with expansion and retention both moving in the right direction.'; } else if (w === 'churn') { if (lower.indexOf('driv') !== -1 || lower.indexOf('why') !== -1) reply = 'Churn dropped 0.8 points to 3.2%. Most likely driven by improved onboarding or a cohort maturing past the risky first 90 days.'; @@ -2500,27 +1388,27 @@

Your AI. Your UI. Finally connected.

} else if (w === 'arpu') { if (lower.indexOf('improv') !== -1 || lower.indexOf('how') !== -1) reply = 'To lift ARPU: audit discount depth, push expansion offers to Starter/Business users, and check if new signups are landing on lower tiers than expected.'; - else reply = 'ARPU slipped 2.1% to $42.10. Watch plan mix — topline growth can mask dilution from discounting.'; + else reply = 'ARPU slipped 2.1% to $42.10. Watch plan mix: topline growth can mask dilution from discounting.'; } else if (w === 'nps') { if (lower.indexOf('do') !== -1 || lower.indexOf('action') !== -1 || lower.indexOf('next') !== -1) reply = 'With NPS at 67, focus on converting promoters into case studies or referrals, and identify passives who might become promoters with one targeted win.'; - else reply = 'NPS is 67, well above the 44 benchmark. Customer sentiment is strong — good leverage for expansion.'; + else reply = 'NPS is 67, well above the 44 benchmark. Customer sentiment is strong: good leverage for expansion.'; } else if (w === 'account_row') { var r = responses.account_row(meta); if (lower.indexOf('action') !== -1 || lower.indexOf('next') !== -1 || lower.indexOf('do') !== -1) reply = meta.status === 'at_risk' ? 'Book an EBR this week for ' + meta.company + '. Lead with ROI data and offer a success manager touchpoint.' : meta.status === 'churned' ? 'Run a win-back sequence for ' + meta.company + '. 60-day window is still live. Offer a credit or a new POC.' : - meta.company + ' is growing — queue an upsell conversation for Business plan. Expansion signal is already showing.'; + meta.company + ' is growing: queue an upsell conversation for Business plan. Expansion signal is already showing.'; else reply = r; } else reply = 'I can see this context. Ask about trends, risk, or recommended actions.'; } else if (lower.indexOf('risk') !== -1) { - reply = 'Globex Inc is the top risk — no login for 14 days, $5.2k MRR at stake. Select their row for the full picture.'; + reply = 'Globex Inc is the top risk: no login for 14 days, $5.2k MRR at stake. Select their row for the full picture.'; } else if (lower.indexOf('summar') !== -1 || lower.indexOf('overview') !== -1) { reply = 'MRR is growing, churn is improving, NPS is strong. Globex is the obvious retention priority. Select any card for detail.'; } else if (lower.indexOf('upsell') !== -1) { - reply = 'Umbrella Ltd is the cleanest upsell candidate — healthy status, expansion signal, Starter plan. Select their row for specifics.'; + reply = 'Umbrella Ltd is the cleanest upsell candidate: healthy status, expansion signal, Starter plan. Select their row for specifics.'; } else { - reply = 'Select a KPI, account row, region, lasso, circle, or highlighted text first — I\'ll load its context and give you a grounded answer.'; + reply = 'Select a KPI, account row, region, lasso, circle, or highlighted text first: I\'ll load its context and give you a grounded answer.'; } addMsg('ai', reply); }, 900); @@ -2531,9 +1419,16 @@

Your AI. Your UI. Finally connected.

if (installCopy) { installCopy.addEventListener('click', function() { var el = document.getElementById('copy-icon'); - function flash(label) { if (!el) return; el.textContent = label; setTimeout(function() { el.textContent = '⎘ copy'; }, 1800); } + var status = document.getElementById('copy-status'); + var copyMarkup = el ? el.innerHTML : ''; + function flash(label) { + if (status) status.textContent = label; + if (!el) return; + el.textContent = label === 'Copied' ? 'Done' : 'Retry'; + setTimeout(function() { el.innerHTML = copyMarkup; }, 1800); + } if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText('npm install @askable-ui/react').then(function() { flash('✓ copied'); }).catch(function() { flash('copy manually'); }); + navigator.clipboard.writeText('npm install @askable-ui/react').then(function() { flash('Copied'); }).catch(function() { flash('copy manually'); }); } else { flash('copy manually'); } }); } @@ -2542,70 +1437,50 @@

Your AI. Your UI. Finally connected.

- - diff --git a/site/www/site.css b/site/www/site.css new file mode 100644 index 00000000..7134ba14 --- /dev/null +++ b/site/www/site.css @@ -0,0 +1,432 @@ +@font-face { + font-family: Inter; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('./assets/inter-latin-variable.woff2') format('woff2'); +} + +*, *::before, *::after { box-sizing: border-box; } +* { margin: 0; padding: 0; letter-spacing: 0; } +:root { + --bg: #fff; --surface: #f7f7f8; --ink: #202124; --ink-2: #53545c; --ink-3: #70717a; + --line: #e8e8ec; --line-strong: #d9d9e0; --accent: #6650cc; --accent-light: #f0ecfc; + --green: #347459; --red: #b24448; --yellow: #996316; + --radius-card: 6px; --radius-pill: 6px; + --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +html { scroll-behavior: smooth; scroll-padding-top: 96px; } +body { color: var(--ink); background: var(--bg); font: 400 15px/1.65 var(--font); -webkit-font-smoothing: antialiased; } +button, input, summary { font: inherit; } +button, a, summary { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; appearance: none; -webkit-appearance: none; } +a { color: inherit; text-decoration: none; } +button:focus-visible, a:focus-visible, summary:focus-visible, input:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } +[hidden] { display: none !important; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +.icon { width: 18px; height: 18px; color: inherit; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; flex: none; } +.github-icon { width: 20px; height: 20px; fill: currentColor; } +.page-shell { max-width: 1168px; padding: 0 32px; margin: 0 auto; } + +/* One navigation bar, aligned with the page grid. */ +.site-header { position: sticky; top: 0; z-index: 40; background: rgba(255,255,255,.97); border-bottom: 1px solid var(--line); } +nav { max-width: 1168px; height: 72px; padding: 0 32px; margin: auto; display: flex; justify-content: space-between; align-items: center; gap: 24px; } +.nav-logo { display: inline-flex; align-items: center; gap: 10px; font-size: 19px; font-weight: 680; white-space: nowrap; } +.nav-mark { width: 29px; height: 29px; display: grid; place-items: center; background: var(--ink); color: #fff; border-radius: 7px; font-size: 16px; } +.nav-links { display: flex; align-items: center; gap: 24px; } +.nav-link { font-size: 13px; color: var(--ink-2); transition: color .15s; } +.nav-link:hover { color: var(--accent); } +.nav-gh { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 6px; transition: background .15s; } +.nav-gh:hover { background: var(--surface); } +.nav-menu { display: none; position: relative; } +.nav-menu summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: 13px; } +.nav-menu summary::-webkit-details-marker { display: none; } +.nav-menu-panel { position: absolute; right: -48px; top: calc(100% + 14px); width: 230px; padding: 8px; background: #fff; border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 12px 24px #2021240d; } +.nav-menu-panel a { display: block; padding: 12px; font-size: 14px; border-radius: 4px; } +.nav-menu-panel a:hover { background: var(--surface); } + +/* Compact introduction with the actual interactive workspace directly beneath. */ +.hero { text-align: center; padding: 52px 0 44px; } +.hero-copy h1 { font-size: 52px; font-weight: 600; line-height: 1.15; margin-bottom: 16px; } +.hero-copy > p { max-width: 510px; margin: auto; color: var(--ink-2); font-size: 17px; line-height: 1.6; text-wrap: pretty; } +.hero-actions { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 12px; margin-top: 24px; } +.btn { display: inline-flex; justify-content: center; align-items: center; gap: 10px; min-height: 42px; padding: 10px 18px; border: 1px solid transparent; border-radius: 6px; font-size: 13px; font-weight: 550; white-space: nowrap; transition: background .16s, border-color .16s, transform .16s; } +.btn:active { transform: translateY(1px); } +.btn-primary, .btn-inverse { color: #fff; background: var(--ink); } +.btn-primary:hover, .btn-inverse:hover { background: #383940; } +.btn-secondary, .btn-ghost-white { background: #fff; border-color: var(--line-strong); } +.btn-secondary:hover, .btn-ghost-white:hover { background: var(--surface); } +.btn-icon { width: 42px; padding: 0; } +.install-cmd { display: flex; align-items: center; gap: 16px; min-height: 42px; padding: 9px 12px 9px 14px; border: 1px solid var(--line); background: var(--surface); border-radius: 6px; font: 12px/1.5 var(--mono); color: var(--ink-2); } +.install-cmd:hover { border-color: var(--line-strong); } +.install-cmd .icon { width: 15px; height: 15px; } +.copy-icon { display: grid; place-items: center; min-width: 15px; font: 11px var(--font); } +.section { padding-top: 96px; min-width: 0; } +.section-title { font-size: 32px; line-height: 1.25; font-weight: 560; text-wrap: balance; margin-bottom: 12px; } +.section-sub { color: var(--ink-2); max-width: 560px; font-size: 15px; margin-bottom: 36px; text-wrap: pretty; } +.section-label { color: var(--ink-3); font-size: 12px; margin-bottom: 12px; } +.text-link { display: inline-flex; align-items: center; gap: 8px; color: var(--accent); font-size: 13px; font-weight: 550; } +.text-link:hover { text-decoration: underline; text-underline-offset: 4px; } +h2, h3, p, .pkg-name { overflow-wrap: anywhere; } +code, pre { font-family: var(--mono); } +code { color: var(--accent); font-size: .9em; } +pre { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; } + +/* The demo is a single framed tool; its internal sections are not cards. */ +#demo { padding-top: 0; } +.deck { border: 1px solid var(--line-strong); border-radius: 8px; background: #fff; box-shadow: 0 8px 32px #20212405; } +.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 20px; border-bottom: 1px solid var(--line); } +.workspace-title { display: flex; align-items: center; gap: 10px; } +.workspace-title .icon { color: var(--ink-3); } +#demo .section-title { margin: 0; font-size: 14px; font-weight: 600; } +.workspace-meta { color: var(--ink-3); font-size: 12px; } +.demo-controls { border-bottom: 1px solid var(--line); } +.pattern-dock { position: relative; padding: 8px 12px; } +.pattern-dock-header, .pattern-copy-mini small, .pattern-payload, .tool-hint { display: none; } +.pattern-grid { display: flex; flex-wrap: wrap; gap: 4px; } +.pattern-option { display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 8px 11px; height: 40px; min-height: 40px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: var(--ink-2); transition: background .15s, color .15s; } +.pattern-option:hover { background: var(--surface); } +.pattern-option.active { color: var(--accent); background: var(--accent-light); border-color: #e1d9f8; } +.pattern-glyph { display: inline-flex; } +.pattern-copy-mini { display: flex; align-items: center; } +.pattern-copy-mini b { font-size: 12px; line-height: 16px; font-weight: 500; white-space: nowrap; } +.pattern-status { position: absolute; right: 12px; top: -46px; } +.deck:has(.pattern-option.active:not([data-mode])) .workspace-meta { visibility: hidden; } +.tool-btn { min-height: 40px; padding: 8px 10px; border: 0; color: var(--ink-2); background: #fff; border-radius: 5px; font-size: 12px; } +.tool-btn:hover { background: var(--surface); } +.demo-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; } +.dashboard { position: relative; min-width: 0; padding: 24px; background: #fafafb; border-bottom-left-radius: 8px; } +.kpi-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 28px; gap: 1px; background: var(--line); } +.kpi-card { position: relative; min-width: 0; padding: 14px 16px; background: #fafafb; cursor: pointer; transition: background .16s, box-shadow .16s; } +.kpi-card:hover { background: #f3f2f7; } +.kpi-card.active { background: var(--accent-light); box-shadow: inset 3px 0 0 var(--accent); } +.kpi-label { color: var(--ink-2); font-size: 12px; margin-bottom: 7px; } +.kpi-value { font-size: 27px; font-weight: 580; font-variant-numeric: tabular-nums; line-height: 1.25; margin-bottom: 6px; } +.kpi-change { font-size: 11px; } +.up { color: var(--green); } .down { color: var(--red); } +.panel { min-width: 0; } +.panel-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; font-size: 12px; font-weight: 600; } +.panel-header span { color: var(--ink-3); font-size: 11px; font-weight: 400; } +table { width: 100%; border-collapse: collapse; font-size: 12px; } +th { padding: 9px 8px; text-align: left; font-size: 10px; font-weight: 500; color: var(--ink-3); border-bottom: 1px solid var(--line-strong); } +td { padding: 13px 8px; border-bottom: 1px solid var(--line); } +th:first-child, td:first-child { padding-left: 0; } +tbody tr:last-child td { border-bottom: 0; } +tbody tr[data-askable] { cursor: pointer; transition: background .16s; } +tbody tr[data-askable]:hover { background: #f1f0f5; } +tbody tr[data-askable].active { background: var(--accent-light); } +.badge { font-size: 10px; white-space: nowrap; } +.badge-green { color: var(--green); } .badge-yellow { color: var(--yellow); } .badge-red { color: var(--red); } +.ask-btn, .ask-btn-row { display: none; align-items: center; gap: 4px; background: #fff; color: var(--accent); border: 1px solid var(--line-strong); border-radius: 4px; font-size: 10px; white-space: nowrap; padding: 5px 7px; } +.ask-btn .icon, .ask-btn-row .icon { width: 12px; height: 12px; } +.ask-btn { position: absolute; top: 9px; right: 9px; } +.button-mode .ask-btn, .button-mode .ask-btn-row { display: inline-flex; } +.button-mode .kpi-label { padding-top: 22px; } +.chat-sidebar { min-width: 0; display: flex; flex-direction: column; min-height: 470px; height: 100%; max-height: 600px; border-left: 1px solid var(--line); } +.chat-header { display: flex; align-items: center; gap: 8px; padding: 16px; border-bottom: 1px solid var(--line); } +.chat-header > .icon { color: var(--accent); width: 16px; height: 16px; } +.chat-title { font-size: 12px; font-weight: 600; } +.chat-sim-badge { margin-left: auto; font-size: 10px; color: var(--ink-3); } +.chat-sub, .chat-dot { display: none; } +.chat-messages { flex: 1 1 0; min-height: 44px; overflow-y: auto; overscroll-behavior: contain; padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.chat-empty { margin: auto; padding: 20px 0; text-align: center; color: var(--ink-3); font-size: 12px; } +.chat-empty > .icon { display: block; width: 24px; height: 24px; margin: 0 auto 12px; color: #b3abc8; } +.msg { display: flex; flex-direction: column; gap: 4px; animation: messageIn .18s ease-out; } +.msg-bubble { width: fit-content; max-width: 100%; padding: 9px 11px; font-size: 12px; line-height: 1.6; background: var(--surface); border-radius: 6px; overflow-wrap: anywhere; } +.msg.user .msg-bubble { background: var(--ink); color: #fff; align-self: flex-end; } +.msg.ctx .msg-bubble { background: none; font: 10px/1.6 var(--mono); color: var(--ink-3); padding: 0; } +.msg-label { color: var(--ink-3); font-size: 10px; } +.msg.user .msg-label { align-self: flex-end; } +.msg-context-kicker { display: block; font: 500 10px/1.5 var(--font); color: var(--accent); margin-bottom: 4px; } +.msg-context-quote { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font: 12px/1.6 var(--font); } +.typing { display: flex; gap: 4px; padding: 8px; } +.typing span { width: 4px; height: 4px; border-radius: 50%; background: var(--ink-3); animation: typing 1s infinite; } +.typing span:nth-child(2) { animation-delay: .15s; } .typing span:nth-child(3) { animation-delay: .3s; } +.chat-context-bar { display: flex; align-items: flex-start; gap: 8px; padding: 12px 16px; background: #f7f5fe; border-top: 1px solid #e9e3fa; font-size: 11px; flex-shrink: 0; } +.context-bar-label { display: none; } +.context-review { flex: 1; min-width: 0; } +.context-review summary { cursor: pointer; color: var(--accent); font-size: 11px; font-weight: 550; } +.context-review pre { max-height: 96px; overflow-y: auto; padding-top: 10px; font: 10px/1.7 var(--mono); color: var(--ink-2); } +.context-dismiss { flex: 0 0 24px; display: grid; place-items: center; width: 24px; height: 24px; margin-top: -3px; border: none; background: none; color: var(--ink-3); } +.context-dismiss .icon { width: 14px; height: 14px; } +.selected-text-card, .selected-region-card { margin: 12px 16px 0; padding-left: 10px; border-left: 2px solid var(--accent); } +.selected-text-card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.selected-text-card-title { color: var(--accent); font-size: 10px; font-weight: 600; } +.selected-text-card-state { color: var(--ink-3); font-size: 10px; } +.selected-text-quote { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: 11px; color: var(--ink-2); } +.selected-region-list { display: grid; gap: 2px; font-size: 11px; color: var(--ink-2); max-height: 64px; overflow: auto; } +.selected-region-list span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dashboard ::selection { background: #ded5fa; color: var(--ink); } +.text-capture-mark { position: absolute; pointer-events: none; z-index: 6; background: #bda7f34d; border-bottom: 2px solid var(--accent); border-radius: 2px; } +.composer-suggestions { display: flex; flex-wrap: wrap; gap: 6px; padding: 12px 16px 0; } +.question-chip { background: #fff; border: 1px solid var(--line); border-radius: 4px; color: var(--ink-2); font-size: 10px; padding: 5px 7px; text-align: left; } +.question-chip:hover { background: var(--surface); border-color: var(--line-strong); } +.chat-input-row { display: flex; gap: 8px; padding: 14px 16px; margin-top: auto; } +.chat-input { min-width: 0; flex: 1; padding: 10px 11px; min-height: 40px; border: 1px solid var(--line-strong); border-radius: 5px; background: #fff; color: var(--ink); font-size: 12px; } +.chat-input::placeholder { color: var(--ink-3); } +.chat-send { display: grid; place-items: center; width: 40px; height: 40px; flex: 0 0 40px; border: 0; border-radius: 5px; background: var(--ink); color: #fff; } +.developer-panel { padding: 12px 20px; border-top: 1px solid var(--line); } +.developer-panel > summary { font: 12px var(--mono); color: var(--ink-2); cursor: pointer; width: fit-content; } +.demo-controls-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.output-controls { padding-top: 20px; } +.toggle-label { font-size: 11px; color: var(--ink-3); } +.toggle-group { display: inline-flex; border: 1px solid var(--line); padding: 2px; border-radius: 5px; } +.toggle-btn { border: 0; background: none; border-radius: 3px; font-size: 11px; color: var(--ink-2); padding: 5px 9px; } +.toggle-btn.active { background: var(--surface); color: var(--ink); } +.code-panel { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin: 16px 0 4px; } +.code-block { min-width: 0; background: var(--surface); border-radius: 5px; padding: 16px; } +.code-block-header { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; color: var(--ink-3); font-size: 10px; } +.code-block-header span:last-child { display: none; } +.code-block pre { font-size: 11px; line-height: 1.7; max-height: 260px; overflow: auto; } +.empty { color: var(--ink-3); } + +/* Distinct reading rhythms rather than repeated card grids. */ +.capability-list { display: grid; grid-template-columns: 1.2fr 1fr 1fr; gap: 36px; padding-top: 8px; } +.capability-list article { min-width: 0; } +.capability-list .icon { color: var(--accent); margin-bottom: 16px; width: 22px; height: 22px; } +.capability-list h3 { font-size: 17px; font-weight: 550; margin-bottom: 10px; line-height: 1.4; } +.capability-list p { color: var(--ink-2); font-size: 13px; line-height: 1.75; margin-bottom: 16px; } +.capability-list .release-note { font-size: 11px; color: var(--ink-3); } +#how { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: 48px; align-items: start; } +.implementation-intro { padding-top: 8px; } +.implementation-intro .text-link { margin-top: 24px; } +.implementation-code { min-width: 0; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; } +.example-tabs { display: flex; padding: 0 12px; border-bottom: 1px solid var(--line); background: var(--surface); } +.example-tab { border: 0; background: none; padding: 14px 12px; color: var(--ink-3); font-size: 12px; border-bottom: 2px solid transparent; } +.example-tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--ink); } +.example-panel { padding: 22px; } +.example-panel p { margin-bottom: 18px; color: var(--ink-2); font-size: 13px; } +.example-panel pre { font-size: 12px; line-height: 1.8; min-height: 168px; color: var(--ink-2); } +.recording { margin-top: 24px; font-size: 13px; color: var(--ink-2); } +.recording summary { cursor: pointer; } +.recording video { margin-top: 20px; width: 100%; aspect-ratio: 16/9; background: var(--surface); border-radius: 6px; } +.pkg-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px 36px; } +.pkg-card { position: relative; min-width: 0; display: block; padding: 0 24px 0 0; } +.pkg-card > .icon { position: absolute; right: 0; top: 3px; width: 15px; height: 15px; color: #95959e; } +.pkg-card:hover .pkg-name, .pkg-card:hover > .icon { color: var(--accent); } +.pkg-badge { font-size: 11px; color: var(--ink-3); margin-bottom: 3px; } +.pkg-name { font: 550 13px/1.6 var(--mono); color: var(--ink); } +.pkg-desc { font-size: 12px; line-height: 1.65; color: var(--ink-2); margin-top: 6px; } +.pkg-tag { display: none; } +#mcp { margin-top: 88px; padding: 48px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.integration-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 36px; } +.integration-list h3 { font-size: 15px; font-weight: 550; margin-bottom: 8px; } +.integration-list p { color: var(--ink-2); font-size: 13px; margin-bottom: 14px; } +.integration-note { max-width: 720px; color: var(--ink-3); font-size: 11px; margin-top: 28px; } +.cta-section { padding: 64px 0; text-align: center; } +.cta-card h2 { font-size: 28px; font-weight: 550; line-height: 1.3; margin-bottom: 12px; } +.cta-card p { color: var(--ink-2); font-size: 14px; max-width: 460px; margin: auto; } +.cta-actions { display: flex; justify-content: center; gap: 10px; margin-top: 24px; } +footer { border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 24px; padding: 24px 0 32px; color: var(--ink-3); font-size: 11px; } +footer a:hover { color: var(--ink); } + + .selection-layer { position: absolute; inset: 0; z-index: 9; pointer-events: none; border-radius: 6px; overflow: visible; } + .dashboard.selecting .selection-layer { pointer-events: auto; cursor: crosshair; } + .dashboard.selecting.lasso-active .selection-layer { cursor: none; } + .selection-layer::before { + content: ''; position: absolute; inset: 0; border-radius: inherit; + border: 1px solid rgba(79,70,229,0.18); background: rgba(79,70,229,0.03); + opacity: 0; transition: opacity .12s; + } + .dashboard.selecting .selection-layer::before { opacity: 1; } + .dashboard.selecting.lasso-active .selection-layer::before { + opacity: 0; + } + .selection-drawing { + position: absolute; border: 2px solid var(--accent); background: rgba(79,70,229,0.09); + box-shadow: 0 10px 28px rgba(79,70,229,0.12); pointer-events: none; z-index: 11; + } + .selection-drawing.circle { border-radius: 999px; } + .selection-drawing.lasso { background: transparent; border: none; box-shadow: none; inset: 0; filter: drop-shadow(0 0 10px rgba(79,70,229,0.32)); } + .selection-drawing svg { position: absolute; inset: 0; overflow: visible; } + .selection-drawing path { fill: rgba(79,70,229,0.08); stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; } + .selection-drawing.lasso path { + fill: none; + stroke: url(#lasso-gradient); + stroke-width: 3.2; + stroke-linecap: round; + stroke-linejoin: round; + } + .selection-drawing.lasso .lasso-aura { + fill: none; + stroke: rgba(124,58,237,0.16); + stroke-width: 8; + stroke-linecap: round; + stroke-linejoin: round; + filter: blur(1px); + } + .selected-region { + position: absolute; z-index: 12; pointer-events: none; + border: 2px solid rgba(124,58,237,0.78); + background: rgba(124,58,237,0.08); + box-shadow: 0 0 0 3px rgba(124,58,237,0.04); + animation: selectedRegionIn .18s ease-out; + } + .selected-region.circle { border-radius: 999px; } + .selected-region.lasso { inset: 0; border: none; background: transparent; box-shadow: none; } + .selected-region svg { position: absolute; inset: 0; overflow: visible; } + .selected-region path { + fill: none; + stroke: rgba(124,58,237,0.9); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 0 7px rgba(124,58,237,0.24)); + } + .selected-region-label { + position: absolute; left: 0; bottom: calc(100% + 7px); + display: inline-flex; align-items: center; gap: .35rem; + padding: .28rem .52rem; border-radius: var(--radius-pill); + border: 1px solid rgba(124,58,237,0.18); background: rgba(255,255,255,0.94); + color: #5b21b6; font-size: .66rem; font-weight: 850; + letter-spacing: 0; text-transform: uppercase; + box-shadow: 0 10px 24px rgba(15,23,42,0.1); white-space: nowrap; + } + .selected-region-label::before { + content: ''; width: .42rem; height: .42rem; border-radius: 999px; + background: #7c3aed; box-shadow: 0 0 0 4px rgba(124,58,237,0.1); + } + .selection-inline-prompt { + position: absolute; left: 0; top: calc(100% + 9px); z-index: 13; + display: flex; align-items: center; gap: .42rem; + width: min(310px, calc(100vw - 3rem)); padding: .38rem; + border: 1px solid rgba(124,58,237,0.18); border-radius: var(--radius-pill); + background: rgba(255,255,255,0.96); box-shadow: 0 16px 42px rgba(15,23,42,0.15); + pointer-events: auto; + } + .selection-inline-prompt.above { top: auto; bottom: calc(100% + 9px); } + .selection-inline-prompt input { + min-width: 0; flex: 1; border: none; outline: none; background: transparent; + color: var(--ink); font: 500 .78rem/1.2 var(--font); padding: .26rem .32rem; + } + .selection-inline-prompt button { + flex: 0 0 auto; border: none; border-radius: var(--radius-pill); + background: var(--ink); color: #fff; padding: .36rem .62rem; + font-size: .7rem; font-weight: 800; cursor: pointer; + } + .text-selection-inline-prompt { + z-index: 14; + box-shadow: 0 16px 42px rgba(91,33,182,0.16); + } + @keyframes selectedRegionIn { + from { opacity: 0; transform: scale(.985); } + to { opacity: 1; transform: scale(1); } + } + .ai-cursor-canvas { + position: absolute; inset: 0; z-index: 10; + width: 100%; height: 100%; + pointer-events: none; opacity: 0; + transition: opacity .12s ease; + } + .dashboard.lasso-active .ai-cursor-canvas { opacity: 1; } + .ai-cursor { + position: absolute; left: 0; top: 0; z-index: 12; + display: none; width: 1.95rem; height: 1.95rem; + transform: translate3d(0,0,0); pointer-events: none; + filter: + drop-shadow(0 2px 4px rgba(15,23,42,0.18)) + drop-shadow(0 0 7px rgba(124,58,237,0.34)); + } + .dashboard.lasso-active .ai-cursor { display: block; } + .ai-cursor-icon { + position: absolute; left: 50%; top: 50%; + display: block; width: 1.95rem; height: 1.95rem; + overflow: visible; transform: translate(-45%, -45%); + } + .ai-cursor-outline { + fill: none; + stroke: #a78bfa; + stroke-linejoin: round; + stroke-width: 4.6; + } + .ai-cursor-fill { + fill: #fff; + stroke: #7c3aed; + stroke-linejoin: round; + stroke-width: 1.2; + } + +@keyframes messageIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } +@keyframes typing { 50% { opacity: .3; } } +@media (max-width: 1040px) { + .demo-layout { grid-template-columns: minmax(0, 1fr) 300px; } + .dashboard { padding: 16px; } + .pattern-option { padding-inline: 8px; gap: 5px; } + .kpi-value { font-size: 24px; } + .panel-header span { display: none; } + .demo-layout th:nth-child(3), .demo-layout td:nth-child(3) { display: none; } +} +@media (max-width: 720px) { + .page-shell { padding: 0 20px; } + nav { height: 64px; padding: 0 20px; gap: 12px; } + .nav-logo { font-size: 17px; } + .nav-links { gap: 16px; } + .nav-link { display: none; } + .nav-menu { display: block; } + .hero { padding: 40px 0 32px; } + .hero-copy h1 { font-size: 36px; max-width: 340px; margin: 0 auto 14px; } + .hero-copy > p { font-size: 15px; max-width: 320px; } + .hero-actions { gap: 10px; margin-top: 22px; } + .btn { padding-inline: 14px; } + .install-cmd { max-width: 100%; font-size: 11px; min-height: 40px; } + .workspace-header { padding: 13px 14px; } + .workspace-title { gap: 7px; } + .workspace-title .icon { width: 16px; height: 16px; } + #demo .section-title { font-size: 12px; } + .workspace-meta { font-size: 10px; } + .pattern-dock { padding: 6px; } + .pattern-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; } + .pattern-option { height: 56px; min-height: 56px; padding: 6px 2px; gap: 4px; flex-direction: column; } + .pattern-option .icon { width: 17px; height: 17px; } + .pattern-copy-mini b { font-size: 10px; line-height: 14px; } + .tool-btn { min-height: 36px; } + .pattern-status { top: -40px; right: 8px; } + .demo-layout { grid-template-columns: minmax(0, 1fr); } + .dashboard { padding: 14px; } + .kpi-grid { margin-bottom: 24px; } + .kpi-card { padding: 12px; } + .kpi-label { font-size: 10px; } + .kpi-value { font-size: 22px; } + .kpi-change { font-size: 9px; } + .panel-header { margin-bottom: 8px; } + table { font-size: 11px; } + td { padding: 11px 5px; } + th { padding-inline: 5px; font-size: 9px; } + .ask-btn { right: 5px; } + .ask-btn-row { font-size: 0; padding: 7px; } + .ask-btn-row .icon { width: 13px; height: 13px; } + .chat-sidebar { border-left: 0; border-top: 1px solid var(--line); height: 350px; min-height: 350px; max-height: none; } + .chat-header { padding: 12px 14px; } + .chat-messages { padding: 14px; } + .developer-panel { padding: 14px; } + .code-panel { grid-template-columns: minmax(0, 1fr); } + .section { padding-top: 64px; } + .section-title { font-size: 26px; } + .section-sub { font-size: 14px; margin-bottom: 28px; } + .capability-list { grid-template-columns: minmax(0, 1fr); gap: 28px; } + .capability-list article { display: grid; grid-template-columns: 26px minmax(0, 1fr); column-gap: 16px; } + .capability-list article > .icon { grid-column: 1; grid-row: 1 / span 4; margin-top: 2px; } + .capability-list article > :not(.icon) { grid-column: 2; } + .capability-list h3 { font-size: 16px; margin-bottom: 6px; } + .capability-list p { margin-bottom: 10px; } + #how { grid-template-columns: minmax(0, 1fr); gap: 24px; } + .implementation-intro .section-sub { margin-bottom: 0; } + .implementation-intro .text-link { margin-top: 16px; } + .example-panel { padding: 18px; } + .example-panel pre { font-size: 11px; min-height: 180px; } + .pkg-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 20px; } + .pkg-card { padding-right: 0; } + .pkg-card > .icon { display: none; } + .pkg-name { font-size: 11px; } + .pkg-desc { font-size: 11px; } + #mcp { margin-top: 56px; padding: 36px 0; } + .integration-list { grid-template-columns: minmax(0, 1fr); gap: 28px; } + .cta-section { padding: 48px 0; } + .cta-card h2 { font-size: 24px; } + footer { flex-direction: column; gap: 14px; } +} +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } +}
CompanyMRRPlanStatus
' + d.company + '' + d.mrr + '' + d.plan + '' + statusLabel[d.status] + '