diff --git a/DESIGN_AIRBNB.md b/DESIGN_AIRBNB.md new file mode 100644 index 000000000..92c691938 --- /dev/null +++ b/DESIGN_AIRBNB.md @@ -0,0 +1,217 @@ +## Overview + +Airbnb is the canonical example of a generous, photography-led consumer marketplace. The base canvas is **pure white** (`{colors.canvas}` — #ffffff) with deep near-black ink (`{colors.ink}` — #222222) for headlines and body, and a single voltage of **Rausch** (`{colors.primary}` — #ff385c) carrying every primary CTA, the search-button orb, the heart save state, and inline brand links. There is no secondary brand color in mainline marketing — the **Luxe purple** (`{colors.luxe}` — #460479) and **Plus magenta** (`{colors.plus}` — #92174d) tokens are sub-brand accents that only appear inside Airbnb Luxe / Plus contexts. + +Type runs **Airbnb Cereal VF** (a custom variable font Airbnb licenses), with **Circular** as the historic in-house fallback and a system stack underneath. Cereal sits at modest weights — display headlines render at 22–28px in weight 500–600, not the heavy 700+ weights that financial or enterprise systems lean on. The hero h1 ("Inspiration for future getaways") on the homepage is just 28px / 700, which would feel small on a typical SaaS page; here it works because the layout leans on photography (city collage, property cards) for visual weight rather than typographic muscle. + +The shape language is **soft**. Buttons are 8px radius (`{rounded.sm}`), property cards are ~14px (`{rounded.md}`), the search bar is fully pill-shaped (`{rounded.full}`), wishlist hearts and search orbs are circles (`{rounded.full}`), and category strip rounded corners run at 32px (`{rounded.xl}`). There is essentially no hard corner anywhere except the body grid itself — every interactive element is rounded. + +**Key Characteristics:** +- Single accent color: `{colors.primary}` (#ff385c — "Rausch") carries every primary CTA, the search orb, the heart save state, and the brand wordmark. Used scarcely — most pages are 90% white + ink with one or two Rausch moments. +- Custom variable type: `Airbnb Cereal VF`. Display weights sit at 500–700, body at 400. Modest weight is intentional — the system trusts photography for visual heft. +- Three-product top nav: Homes, Experiences, Services — each with a hand-illustrated 32px icon and "NEW" badges (`{component.new-tag}`) on the two newer products. Active tab uses an underline rule (`{component.product-tab-active}`). +- Pill-shaped global search bar: white surface, fully rounded (`{rounded.full}`), divided by 1px hairlines into Where / When / Who segments, terminated by a circular Rausch search orb (`{component.search-orb}`). +- Property cards are photo-first: aspect-ratio rectangles with `{rounded.md}` corner clipping, swipeable image carousel, "Guest favorite" floating badge top-left, heart icon top-right, then 4–5 lines of meta beneath. +- Editorial dropdowns (footer, language picker) are clean text columns over the white canvas — no card surface, no shadow. +- The design system caps elevation at one shadow tier (`box-shadow: rgba(0,0,0,0.02) 0 0 0 1px, rgba(0,0,0,0.04) 0 2px 6px, rgba(0,0,0,0.1) 0 4px 8px`) — used on hover-floated cards and search/account dropdowns. +- 8px base spacing system, with major sections at `{spacing.section}` (64px) — generous but not airy enough to feel editorial-magazine; the marketplace density wants more cards per scroll. + +## Colors + +### Brand & Accent +- **Rausch** (`{colors.primary}` — #ff385c): The single brand color. Used for primary CTA backgrounds (Reserve, Continue), the search orb, the heart save state on property cards, and inline brand links. The most recognizable color in consumer travel. +- **Rausch Active** (`{colors.primary-active}` — #e00b41): The press / pointer-down variant — slightly more saturated. Used on `{component.button-primary-active}`. +- **Rausch Disabled** (`{colors.primary-disabled}` — #ffd1da): A pale tint used on disabled CTAs. +- **Luxe Purple** (`{colors.luxe}` — #460479): Sub-brand accent for Airbnb Luxe. Only appears inside Luxe-branded surfaces — never in mainline marketing. +- **Plus Magenta** (`{colors.plus}` — #92174d): Sub-brand accent for Airbnb Plus. Same scoping as Luxe — sub-product only. + +### Surface +- **Canvas** (`{colors.canvas}` — #ffffff): The default page floor for every public page. Airbnb does not have a dark mode on the public web. +- **Surface Soft** (`{colors.surface-soft}` — #f7f7f7): The lightest fill — used on disabled fields, sub-nav hover backgrounds, and the inline search filter band. +- **Surface Strong** (`{colors.surface-strong}` — #f2f2f2): Slightly heavier fill — circular icon-button surface (e.g., the breadcrumb back-arrow and listing toolbar buttons). + +### Hairlines & Borders +- **Hairline** (`{colors.hairline}` — #dddddd): The default 1px border tone — search bar dividers, table separators, footer column splitters, card 1px borders. +- **Hairline Soft** (`{colors.hairline-soft}` — #ebebeb): A lighter divider used on long-scrolling editorial body separators. +- **Border Strong** (`{colors.border-strong}` — #c1c1c1): A heavier stroke used on disabled outline buttons and form input outlines after focus. + +### Text +- **Ink** (`{colors.ink}` — #222222): The dominant text color on light surfaces. Display headlines, body paragraphs, primary nav links, and most inline link text. Never pure black. +- **Body** (`{colors.body}` — #3f3f3f): A secondary running-text color used inside long-form review and amenity copy where ink would feel too heavy. +- **Muted** (`{colors.muted}` — #6a6a6a): Sub-titles inside city link blocks ("Cottage rentals", "Villa rentals"), inactive product-tab labels, footer category sub-labels, "View all" links. +- **Muted Soft** (`{colors.muted-soft}` — #929292): Disabled link text. Used very sparingly. +- **Star Rating** (`{colors.star-rating}` — #222222): The same ink token — Airbnb's star icon and "4.81" rating numbers all render in ink rather than a yellow/gold color, which is a deliberate brand choice (yellow stars feel cheap in travel context). +- **On Primary** (`{colors.on-primary}` — #ffffff): White text on Rausch CTAs. + +### Semantic +- **Error** (`{colors.primary-error-text}` — #c13515): Inline error text for form validation. Distinct from Rausch — slightly darker, more saturated red. +- **Error Hover** (`{colors.primary-error-text-hover}` — #b32505): Darkens on link hover. +- **Legal Link Blue** (`{colors.legal-link}` — #428bff): Inline links inside legal copy (Privacy, Terms). Only used inside the legal sub-band. + +### Scrim +- **Scrim** (`{colors.scrim}` — #000000 at 50% opacity): The global modal backdrop tone — date picker, login dialog, language picker. Stored as the base hex; opacity is applied at render time. + +## Typography + +### Font Family +The system runs **Airbnb Cereal VF** for everything — display, body, navigation, captions, microcopy. Fallbacks walk `Circular, -apple-system, system-ui, Roboto, "Helvetica Neue", sans-serif`. **Circular** is the historic in-house typeface still kept as the first non-variable fallback; system stacks back it up. + +There is no separate display family. The variable font carries the entire scale. + +### Hierarchy + +| Token | Size | Weight | Line Height | Letter Spacing | Use | +|---|---|---|---|---|---| +| `{typography.rating-display}` | 64px | 700 | 1.1 | -1px | Listing detail rating display ("4.81") | +| `{typography.display-xl}` | 28px | 700 | 1.43 | 0 | Homepage h1 ("Inspiration for future getaways") | +| `{typography.display-lg}` | 22px | 500 | 1.18 | -0.44px | Listing detail h1 ("Close to Fethiye Aliyah Bali Beach…") | +| `{typography.display-md}` | 21px | 700 | 1.43 | 0 | Section heads inside listing detail ("What this place offers") | +| `{typography.display-sm}` | 20px | 600 | 1.20 | -0.18px | Sub-section titles ("Things to know") | +| `{typography.title-md}` | 16px | 600 | 1.25 | 0 | City link block titles ("Wilmington", "Athens") | +| `{typography.title-sm}` | 16px | 500 | 1.25 | 0 | Footer column heads ("Support", "Hosting", "Airbnb") | +| `{typography.body-md}` | 16px | 400 | 1.5 | 0 | Default running-text inside listing copy | +| `{typography.body-sm}` | 14px | 400 | 1.43 | 0 | Card meta lines, dates, prices, distance text | +| `{typography.caption}` | 14px | 500 | 1.29 | 0 | Search field segment labels ("Where", "When", "Who") | +| `{typography.caption-sm}` | 13px | 400 | 1.23 | 0 | Footer legal line ("© 2026 Airbnb, Inc.") | +| `{typography.badge}` | 11px | 600 | 1.18 | 0 | "Guest favorite" floating badge text | +| `{typography.micro-label}` | 12px | 700 | 1.33 | 0 | Card amenity micro-labels ("Inline 6") | +| `{typography.uppercase-tag}` | 8px | 700 | 1.25 | 0.32px (uppercase) | "NEW" badge on product nav tabs | +| `{typography.button-md}` | 16px | 500 | 1.25 | 0 | Primary CTA button labels | +| `{typography.button-sm}` | 14px | 500 | 1.29 | 0 | Pill button labels (category strip) | +| `{typography.link}` | 14px | 400 | 1.43 | 0 | Inline body links | +| `{typography.nav-link}` | 16px | 600 | 1.25 | 0 | Top product-nav labels (Homes, Experiences, Services) | + +### Principles +Display weights stay modest. The homepage h1 at 28px / 700 is deliberately small — it tucks under the search bar so photography and the city-link grid carry visual hierarchy. The listing-detail h1 at 22px / 500 is even quieter; the listing photo banner does the work above it. + +The single typographically loud moment in the entire system is the **rating display** (`{typography.rating-display}` — 64px / 700) on listing pages. That is the only place the system trusts type alone to carry hierarchy — rating numbers are a peak trust signal, so they get the loudest treatment. + +### Note on Font Substitutes +If Airbnb Cereal VF and Circular are unavailable, **Inter** is the closest open-source substitute. Adjust display headlines down by ~2% in line-height to match Cereal's slightly tighter cap height; otherwise the proportions transfer cleanly. + +## Layout + +### Spacing System +- **Base unit:** 4px (with 2px micro-step). +- **Tokens:** `{spacing.xxs}` 2px · `{spacing.xs}` 4px · `{spacing.sm}` 8px · `{spacing.md}` 12px · `{spacing.base}` 16px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 64px. +- **Section padding (vertical):** `{spacing.section}` (64px) for major page bands; tighter than typical SaaS marketing (80–96px) because marketplace pages need higher card density per scroll. +- **Card internal padding:** `{spacing.lg}` (24px) for `{component.host-card}` and `{component.reservation-card}`; `{spacing.base}` (16px) for property-card meta block; `{spacing.sm}` (8px) for caption / date-row gutters. +- **Gutters:** `{spacing.base}` (16px) between cards in the homepage city grid; `{spacing.lg}` (24px) inside footer column gutters; `{spacing.xs}` (4px) on dense category-strip dividers. + +### Grid & Container +- **Max content width:** ~1280px centered on the homepage and editorial pages. Listing detail pages cap closer to 1080px to keep the photo banner and reservation rail readable. +- **City link grid (homepage footer):** 6-column grid at desktop with each cell housing a city name in `{typography.title-md}` and a category sub-label in `{typography.body-sm}` muted. +- **Listing detail:** 2-column with photo / amenity body on the left (~64% width) and a sticky reservation card (`{component.reservation-card}`) on the right (~32%). +- **Footer:** 3-column link list (Support / Hosting / Airbnb) at desktop, collapsing to 1-column on mobile. + +### Whitespace Philosophy +The system gives editorial bands 64px of vertical breathing room but compresses card grids — property and city-link cards sit just 16px apart. The contrast is intentional: the page reads as "open hero, dense marketplace below," reinforcing the marketplace nature without overwhelming the visitor at the fold. + +## Elevation + +The system has essentially **one shadow tier** plus the flat baseline. + +- **Flat (no shadow):** Body, hero, footer, all editorial bands — 95% of surfaces. +- **Card hover float:** `box-shadow: rgba(0, 0, 0, 0.02) 0 0 0 1px, rgba(0, 0, 0, 0.04) 0 2px 6px 0, rgba(0, 0, 0, 0.1) 0 4px 8px 0` — applied to property cards on pointer hover, the search bar at rest, and the dropdown menus (account menu, language picker, date picker). This is the single shadow definition in the entire system. +- **Modal scrim:** `{colors.scrim}` rendered at 50% opacity — the global modal backdrop. Used on date pickers, login dialogs, language picker. + +There are no progressive elevation tiers — the system either has the one shadow or none. Depth comes from photography, the white-on-white surface separation, and rounded-corner clipping rather than from layered shadows. + +## Components + +### Buttons + +**`button-primary`** — Rausch fill, white text, 8px radius, 14×24px padding, 48px height, weight 500. The most common CTA across the system: "Reserve", "Continue", "Search", account-flow primaries. + +**`button-primary-active`** — The press state. Background flips to `{colors.primary-active}`. No transform, no shadow change. + +**`button-primary-disabled`** — Pale Rausch tint at #ffd1da with white text. Cursor not-allowed. + +**`button-secondary`** — White fill with ink text and a 1px ink outline. 8px radius. Used for "Save", "Cancel", and inverse CTAs over Rausch surfaces. + +**`button-tertiary-text`** — Plain ink text, no surface, no border. Underlined on hover. Used for "Show more" type links and modal close labels. + +**`button-pill-rausch`** — A pill-shaped Rausch CTA used on featured cells (e.g., "Become a host" sub-CTA) — 9999px radius, 10×20px padding, 14px label. + +### Search Surface + +**`search-bar-pill`** — The signature global search bar. White fill, 9999px radius, 64px height, 1px hairline 1px-shadow border. Internally divided by vertical hairline rules into `{component.search-field-segment}` cells (Where / When / Who). Each segment holds an uppercase caption label above a placeholder line in `{typography.caption}`. + +**`search-orb`** — The circular Rausch orb terminating the right edge of the search bar. 48×48px, fully rounded, white magnifying-glass icon centered. The hottest single color moment on the homepage. + +### Top Navigation + +**`top-nav`** — White surface, 80px height, 1px bottom hairline. The Airbnb wordmark sits flush left, the three product tabs (Homes / Experiences / Services) sit in the dead center, and account utilities (host link, language globe, account menu) sit flush right. + +**`product-tab-active`** — Ink label in `{typography.nav-link}`, 32px hand-illustrated icon, 2px ink underline rule beneath the icon-label pair. + +**`product-tab-inactive`** — Muted label, illustrated icon, no underline. Becomes active on click. + +**`new-tag`** — A tiny rounded-pill badge (`{rounded.full}`) anchored top-right of an icon, carrying the uppercase "NEW" label in `{typography.uppercase-tag}` (8px / 700 with 0.32px tracking, uppercase). Used on Experiences and Services to signal recency. + +### Listing Cards + +**`property-card`** — A photo-first card. 1:1 aspect-ratio image with `{rounded.md}` corner clipping, image carousel dots overlay, "Guest favorite" floating badge top-left (`{component.guest-favorite-badge}`), and a heart icon top-right (`{component.icon-button-circle}` in default outlined state, Rausch-filled when saved). Beneath the image: 4–5 lines of meta — title (`{typography.title-md}`), distance / dates (`{typography.body-sm}` muted), and price ("$X night") right-aligned. + +**`property-card-photo`** — The photo plate itself, separated as a token because some surfaces (wishlist, search results) reuse just the photo without the meta block. + +**`experience-card`** — A taller-aspect card (4:5) for experience listings. Same `{rounded.md}` clipping, floating "NEW" badge top-left, heart top-right, and a single-line title beneath. + +**`guest-favorite-badge`** — White rounded pill (`{rounded.full}`) at 11px / 600 weight. Sits over the photo with the system's only shadow tier applied for elevation. + +### Listing Detail + +**`rating-display-card`** — The signature listing-detail moment. A 64px / 700 rating number ("4.81") flanked left and right by tiny laurel-wreath SVG ornaments. Beneath the rating: "Guest favorite" tagline and a row of ink stat columns. The largest typographic weight in the whole system. + +**`amenity-row`** — A 1-column list of amenity icons + ink labels in `{typography.body-md}`. 12px row padding, no border between rows; section is closed by a 1px hairline divider above and below. + +**`reviews-card`** — A 2-column grid of review excerpts. Each column holds an author row (avatar, name, date) above a 3-line excerpt with "Show more" tertiary link. + +**`host-card`** — A white card with `{rounded.md}` rounding and 24px padding holding a host avatar, name, "Superhost" badge, response-rate stat, and a "Contact host" `{component.button-secondary}`. + +**`reservation-card`** — The sticky right-rail card on listing detail pages. White surface, `{rounded.md}` rounding, 1px hairline border, 1px shadow tier elevation, 24px padding. Contains: nightly price (`{typography.display-md}` ink), date-range selector, guest-count stepper, "Reserve" primary CTA full-width, and a fee breakdown stack beneath in `{typography.body-sm}`. + +### Date Picker + +**`date-picker-day`** — A 40×40px circular cell carrying the day number in `{typography.body-sm}`. Default state is transparent fill, ink text. + +**`date-picker-day-selected`** — Ink fill, white text, full circle (`{rounded.full}`). Range states between two selected days carry a `{colors.surface-soft}` lozenge background that connects them. + +### Forms + +**`text-input`** — White surface, 1px hairline outline, `{rounded.sm}` 8px radius, 56px height, 14×12px padding. Stacked label above (in `{typography.caption}` muted), placeholder text in `{typography.body-md}` muted. On focus, the border thickens to 2px ink and the border color flips to `{colors.ink}` — no glow, no ring. + +### Footer + +**`footer-light`** — White surface (matches the page canvas — Airbnb has no contrast footer), 48×80px padding. Three columns of link blocks (Support / Hosting / Airbnb), separated by generous 24px gutters. Each column heads with a `{typography.title-sm}` ink label and stacks `{component.footer-link}` rows in `{typography.body-sm}` ink. + +**`legal-band`** — A bottom strip beneath the footer columns carrying the copyright line, language picker (globe icon + "English (US)" link), currency picker, and social icons (Facebook, X, Instagram). All text in muted `{colors.muted}` at `{typography.caption-sm}`. + +## Responsive Behavior + +| Name | Width | Key Changes | +|---|---|---| +| Mobile | < 744px | Top nav collapses to logo + hamburger; product tabs hide behind a sheet; search bar collapses to a single tappable pill; property cards stack 1-up; city grid 1-column; listing detail collapses reservation card to a sticky bottom bar. | +| Tablet | 744–1128px | Top nav keeps product tabs but search bar narrows; property cards 2-up; city grid 2–3 column; reservation card stays sticky right-rail at narrower width. | +| Desktop | 1128–1440px | Full top nav with three product tabs centered; search bar at full pill width with all 3 segments visible; property cards 4-up; city grid 6-column; listing detail 2-column with reservation rail. | +| Wide | > 1440px | Content width caps at 1440px on listing/search pages and ~1280px on editorial; gutters absorb the rest. | + +### Touch Targets +- Primary CTAs at minimum 48×48px (above WCAG AAA). +- Search orb is 48×48px circular — the most-tapped element on the page. +- Heart save button is 32×32px circular — borderline for AAA but compensated by a generous 12px padding inside the photo card. +- Date-picker day cells are 40×40px circular. + +### Collapsing Strategy +- Top product tabs collapse into a hamburger sheet below 744px. +- Search bar's 3 segments collapse into a single-tap entry that opens a full-screen search overlay on mobile. +- Property and city-link grids drop column counts cleanly at each breakpoint — never reflow rows; always reduce columns. +- Reservation card on listing detail switches from sticky right-rail to a sticky bottom bar on mobile, carrying just the "Reserve" CTA + nightly price summary. + +## Known Gaps + +- **Hover state colors:** intentionally not documented per the global no-hover policy — Airbnb's actual `:hover` styling for property cards is a subtle elevation lift, but precise extraction is unreliable. +- **Loading states / skeleton screens:** not visible on the extracted surfaces. +- **Map view styling:** the search-results map uses Mapbox-tinted tiles with custom Rausch markers; not captured here. +- **Form input error states:** error text color (`{colors.primary-error-text}`) is documented, but the full input outline + helper-text combination on validation failure was not visible in the captured surfaces. +- **Sub-brand palettes:** Luxe (`{colors.luxe}`) and Plus (`{colors.plus}`) are documented as tokens, but their full sub-system (typography overrides, surface treatment) lives on separate sub-domains and is not captured here. diff --git a/DESIGN_APPLE.md b/DESIGN_APPLE.md new file mode 100644 index 000000000..b2b169924 --- /dev/null +++ b/DESIGN_APPLE.md @@ -0,0 +1,287 @@ +## Overview + +Apple's web presence is a masterclass in **reverent product photography framed by near-invisible UI**. Every page is a stack of edge-to-edge product "tiles" — alternating light and dark canvases, each centered on a hero headline, a one-line tagline, two tiny blue pill CTAs, and an impossibly crisp product render. Nothing competes with the product. Typography is confident but quiet; color is either pure white, an off-white parchment, or a near-black tile; interactive elements are a single, quiet blue. + +Density is unusually low even by contemporary SaaS standards. Each tile occupies roughly one viewport, and there is no decorative chrome — no borders, no gradients, no decorative frames, no shadows on headlines. Elevation appears only when a product image rests on a surface (a single soft `rgba(0, 0, 0, 0.22) 3px 5px 30px` drop for visual weight). The result is a catalog that feels more like a museum gallery: the wall disappears and the artifact takes over. + +Store and shop surfaces retain the same chassis but switch modes. The product configurator (iPhone 17 Pro, accessories grid) introduces a tight grid of white utility cards at `{rounded.lg}` (18px) radius with a thin border, paired with a persistent thin sub-nav strip. The environment page leans darker and more editorial. Across all five surfaces the typographic system, spacing rhythm, and the single blue accent are consistent — this is one design language expressed at different volumes. + +**Key Characteristics:** +- Photography-first presentation; UI recedes so the product can speak. +- Alternating full-bleed tile sections: white/parchment ↔ near-black, with the color change itself acting as the section divider. +- Single blue accent (`{colors.primary}` — #0066cc) carries every interactive element. No second brand color exists. +- Two button grammars: tiny blue pill CTAs (`{rounded.pill}`) and compact utility rects (`{rounded.sm}`). +- SF Pro Display + SF Pro Text — negative letter-spacing at display sizes for the signature "Apple tight" headline feel. +- Whisper-soft elevation used only when a product image needs to breathe — exactly one drop-shadow in the entire system. +- Tight two-row nav: slim `{component.global-nav}` + product-specific `{component.sub-nav-frosted}` with persistent right-aligned primary CTA. +- Section rhythm across multiple pages: light hero → dark product tile → light utility tile → dark tile → parchment footer — a predictable pulse. + +## Colors + +> **Source pages analyzed:** homepage, environment, store, iPhone 17 Pro buy page, accessories index. The color system is identical across all five surfaces; only the surface-mode mix differs. + +### Brand & Accent +- **Action Blue** (`{colors.primary}` — #0066cc): The single brand-level interactive color. All text links, all blue pill CTAs ("Learn more", "Buy"), and the focus ring root. This is Apple's quiet but universal "click me" signal. Press state shifts to a slightly darker variant via the active scale transform rather than a hex change. +- **Focus Blue** (`{colors.primary-focus}` — #0071e3): A marginally brighter sibling of Action Blue, reserved for the keyboard focus ring on buttons (`outline: 2px solid`). +- **Sky Link Blue** (`{colors.primary-on-dark}` — #2997ff): A brighter blue used on dark surfaces for in-copy links and inline callouts, where Action Blue would disappear against the tile background. + +### Surface +- **Pure White** (`{colors.canvas}` — #ffffff): The dominant canvas. Content, utility cards, store tiles, configurator grids. +- **Parchment** (`{colors.canvas-parchment}` — #f5f5f7): The signature Apple off-white. Used for alternating light tiles, footer region, and the default page canvas in store utility sections. Just different enough from white to create rhythm. +- **Pearl Button** (`{colors.surface-pearl}` — #fafafc): A near-white used as the fill for secondary "ghost" buttons — lighter than the parchment canvas so the button still reads as a button against `{colors.canvas-parchment}`. +- **Near-Black Tile 1** (`{colors.surface-tile-1}` — #272729): The primary dark-tile surface on the homepage product grid. +- **Near-Black Tile 2** (`{colors.surface-tile-2}` — #2a2a2c): A micro-step lighter — used where a dark tile sits directly above or below Tile 1 to create the faintest separation. +- **Near-Black Tile 3** (`{colors.surface-tile-3}` — #252527): A micro-step darker — used at the bottom of the stack and in embedded video/player frames. +- **Pure Black** (`{colors.surface-black}` — #000000): Reserved for true void — video player backgrounds, edge-to-edge photographic overlays, the global nav bar background. +- **Translucent Chip Gray** (`{colors.surface-chip-translucent}` — #d2d2d7): The base hex of the translucent gray chip used over photography for circular control buttons. In production, applied at ~64% alpha as `rgba(210, 210, 215, 0.64)`. + +### Text +- **Near-Black Ink** (`{colors.ink}` — #1d1d1f): The voice of every headline, every body paragraph, and the dark utility button's fill. Chosen instead of pure black to keep the page feeling photographic rather than printed. +- **Body** (`{colors.body}` — #1d1d1f): Same hex as ink — Apple uses one near-black tone for all text on light surfaces. +- **Body On Dark** (`{colors.body-on-dark}` — #ffffff): All text on dark tiles and on the global nav bar. +- **Body Muted** (`{colors.body-muted}` — #cccccc): Secondary copy on dark tiles where pure white would be too loud. +- **Ink Muted 80** (`{colors.ink-muted-80}` — #333333): Body text on the white Pearl Button surface — slightly softer than pure black. +- **Ink Muted 48** (`{colors.ink-muted-48}` — #7a7a7a): Disabled button text and legal fine-print. + +### Hairlines & Borders +- **Divider Soft** (`{colors.divider-soft}` — #f0f0f0): The "border" tone on secondary buttons — functions as a ring shadow rather than a hard line. In production, often applied as `rgba(0, 0, 0, 0.04)`. +- **Hairline** (`{colors.hairline}` — #e0e0e0): The 1px hairline border on store utility cards and configurator chips. + +### Brand Gradient +**No decorative gradients.** Atmospheric depth on product photography (the iPhone 17 Pro camera plate, the Apple Watch bands, AirPods reflections) is inherent to the imagery, not a CSS gradient overlay. The environment page's hero uses photographic atmosphere (mountain vista at dawn) but no gradient tokens are defined. Apple is the rare luxury-brand site with zero gradient-based design tokens. + +## Typography + +### Font Family +- **Display**: `SF Pro Display, system-ui, -apple-system, sans-serif` — Apple's proprietary display face, optimized for sizes ≥ 19px. Defines the voice of every headline. +- **Body / UI**: `SF Pro Text, system-ui, -apple-system, sans-serif` — the text-optimized variant used for body copy, captions, buttons, and links below 20px. +- **OpenType features**: `font-variant-numeric: numerator` is enabled on numeric links (pricing tables, spec sheets). Display sizes rely on tight tracking rather than contextual ligatures. + +### Hierarchy + +| Token | Size | Weight | Line Height | Letter Spacing | Use | +|---|---|---|---|---|---| +| `{typography.hero-display}` | 56px | 600 | 1.07 | -0.28px | Hero headline; the signature "Apple tight" tracking | +| `{typography.display-lg}` | 40px | 600 | 1.10 | 0 | Tile headlines atop every product tile | +| `{typography.display-md}` | 34px | 600 | 1.47 | -0.374px | Section heads (SF Pro Text at display proportions) | +| `{typography.lead}` | 28px | 400 | 1.14 | 0.196px | Product tile subcopy | +| `{typography.lead-airy}` | 24px | 300 | 1.5 | 0 | Environment-page lead paragraphs (the rare weight 300) | +| `{typography.tagline}` | 21px | 600 | 1.19 | 0.231px | Sub-tile tagline; sub-nav category name | +| `{typography.body-strong}` | 17px | 600 | 1.24 | -0.374px | Inline strong emphasis | +| `{typography.body}` | 17px | 400 | 1.47 | -0.374px | Default paragraph | +| `{typography.dense-link}` | 17px | 400 | 2.41 | 0 | Footer / store utility link lists (relaxed leading) | +| `{typography.caption}` | 14px | 400 | 1.43 | -0.224px | Secondary captions, button text | +| `{typography.caption-strong}` | 14px | 600 | 1.29 | -0.224px | Emphasized captions | +| `{typography.button-large}` | 18px | 300 | 1.0 | 0 | Store hero CTAs (the rare weight 300) | +| `{typography.button-utility}` | 14px | 400 | 1.29 | -0.224px | Utility/nav button labels | +| `{typography.fine-print}` | 12px | 400 | 1.0 | -0.12px | Fine-print, footer body | +| `{typography.micro-legal}` | 10px | 400 | 1.3 | -0.08px | Micro legal disclaimers | +| `{typography.nav-link}` | 12px | 400 | 1.0 | -0.12px | Global nav menu items | + +### Principles + +- **Negative letter-spacing at display sizes.** Every headline at 17px and up carries a slight tracking tighten (`-0.12 → -0.374px`). This produces the iconic "Apple tight" headline cadence. Never used at 12px or below. +- **Body copy at 17px, not 16px.** Apple breaks the SaaS convention and runs paragraph text at 17px. The extra pixel gives the page an unmistakable "reading, not scanning" pace. +- **Weight 300 is real and rare.** Used deliberately on a handful of large-size reads (`{typography.button-large}` at 18px/300 and `{typography.lead-airy}` at 24px/300). It's not an accident — it's a light-atmosphere cue reserved for moments where the content should feel airy. +- **Weight 600, not 700, for headlines.** Apple's headlines sit at weight 600. Weight 700 is used sparingly for `{typography.tagline}` (21px) when a touch more assertion is needed. +- **Line-height is context-specific.** Display sizes use 1.07–1.19 (tight). Body uses 1.47. Utility link stacks in the footer/store use an unusually relaxed 2.41 (`{typography.dense-link}`). The 2.41 is not a bug — it's how the footer's dense link columns breathe. +- **Weight 500 is deliberately absent.** The ladder is 300 / 400 / 600 / 700. Mid-weight readings always use 600. + +### Note on Font Substitutes +SF Pro is Apple's proprietary system font. When building off-system: + +- Use `system-ui, -apple-system, BlinkMacSystemFont` as the first stack entry — on macOS/iOS/Safari this resolves to the real SF Pro. +- For non-Apple platforms, **Inter** (Google Fonts, variable) is the closest open-source equivalent. Inter at weight 600 with `font-feature-settings: "ss03"` approximates SF Pro's rounded "a" character. +- Nudge `letter-spacing` down by `-0.01em` on display sizes to re-create the Apple tight feel; Inter's default tracking runs slightly wider than SF Pro. +- For body text, tighten line-height by `0.03` (from 1.47 → 1.44) when substituting Inter — Inter's taller x-height needs less leading. + +## Layout + +### Spacing System +- **Base unit:** 8px. Sub-base values (2, 4, 5, 6, 7) are used for tight typographic adjustments; structural layout snaps to 8/12/16/20/24. +- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 17px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 80px. +- **Section vertical padding:** `{spacing.section}` (80px) inside a product tile; tiles stack edge-to-edge with 0 gap (the color change provides the break). +- **Card padding:** `{spacing.lg}` (24px) inside utility grid cards. +- **Button padding:** 8–11px vertical, 15–22px horizontal. +- **Universal rhythm constants:** the 17px body line-height multiplier (~25px line) and 21px tagline size show up on every analyzed page. + +### Grid & Container +- **Max content width:** ~980px on text-heavy sections (environment), ~1440px on product grids (store, accessories), full-bleed for product tiles (homepage). +- **Column patterns:** 3 to 5 column utility card grid on store/accessories; 2-column side-by-side tiles on homepage occasional sections; single-column centered stack on product tile heroes. +- **Gutters:** 20–24px between cards in a utility grid. + +### Whitespace Philosophy +Apple's whitespace is the product's pedestal. Every tile begins with at least 64px of air above its headline and 48–64px below. Product renders are never crowded; the nearest content to a product image is at least 40px away. The footer is the only area that breaks this — there, Apple goes deliberately dense to make the full information architecture visible at a glance. + +## Elevation & Depth + +| Level | Treatment | Use | +|---|---|---| +| Flat | No shadow, no border | Full-bleed tiles, global nav, footer, body sections | +| Soft hairline | 1px `rgba(0, 0, 0, 0.08)` border | Utility cards, sub-nav frosted-glass separator | +| Backdrop blur | `backdrop-filter: blur(N)` on Parchment 80% | Sub-nav and the iPhone buy floating sticky bar | +| Product shadow | `rgba(0, 0, 0, 0.22) 3px 5px 30px 0` | Product renders resting on a surface (the only true "shadow" in the system) | + +**Shadow philosophy.** Apple uses **exactly one** drop-shadow, and it is applied to photographic product imagery — never to cards, never to buttons, never to text. Elevation in the UI comes from (a) surface-color change (light tile ↔ dark tile) and (b) backdrop-blur on sticky bars. The single shadow is about giving the product weight, not about UI hierarchy. + +### Decorative Depth +- **Atmospheric imagery** on the environment page (photographic vista) supplies mood; no CSS gradient involved. +- **Edge-to-edge tile alternation** creates rhythm without borders or shadows — the color change itself is the divider. +- **Backdrop-filter blur** on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` creates a "floating over content" effect that's functional, not decorative. + +## Shapes + +### Border Radius Scale + +| Token | Value | Use | +|---|---|---| +| `{rounded.none}` | 0px | Full-bleed product tiles (no corner rounding) | +| `{rounded.xs}` | 5px | Inline links when styled as subtle chips (rare) | +| `{rounded.sm}` | 8px | Dark utility buttons (Sign In, Bag), inline card imagery | +| `{rounded.md}` | 11px | White Pearl Button capsules | +| `{rounded.lg}` | 18px | Store utility cards, accessories grid cards | +| `{rounded.pill}` | 9999px | Primary blue pill CTAs, sub-nav buy button, configurator option chips, search input — the signature Apple pill | +| `{rounded.full}` | 9999px / 50% | Circular control chips floating over photography | + +### Photography Geometry +- **Hero imagery**: full-bleed, 21:9 or taller on the homepage; 16:9 on environment and shop pages. Product renders are photographic-realistic, often shot on a tinted surface that becomes the tile background. +- **Product renders**: PNG/WebP with transparency; rest on a surface tile and pick up the system shadow. +- **Accessory grid**: square 1:1 crops at `{rounded.lg}` (18px) radius, light neutral backgrounds, product centered with 20–40px internal padding. +- **No rounded imagery in hero tiles** — images are full-bleed rectangular. Rounding (`{rounded.sm}`, `{rounded.lg}`) appears only on inline card imagery. +- Lazy-loading via responsive `srcset` and `sizes` across all breakpoints; CDN-optimized WebP. + +## Components + +### Top Navigation + +**`global-nav`** — Persistent, ultra-thin black nav bar pinned to the top of every page. Background `{colors.surface-black}`, height 44px, text `{colors.on-dark}` in `{typography.nav-link}` (12px / 400 / -0.12px tracking). Links are quiet, spaced ~20px apart, running edge-to-edge across the top. Right-aligned cluster: Search, Bag icons — always visible. On mobile, collapses to hamburger at ~834px and the Apple logo centers. + +**`sub-nav-frosted`** — Surface-specific nav that sticks below the global nav. Background `{colors.canvas-parchment}` at 80% opacity with backdrop-filter blur, creating a frosted-glass effect. Height 52px. Content on left: product category name ("iPhone", "Store", "Accessories") in `{typography.tagline}` (21px / 600). Content right: inline nav links in `{typography.button-utility}` (14px), ending in a persistent `{component.button-primary}` ("Buy") or a utility link. + +### Buttons + +**`button-primary`** — The signature Apple action. Background `{colors.primary}` (Action Blue #0066cc), text `{colors.on-primary}` in `{typography.body}` (SF Pro Text 17px / 400), rounded `{rounded.pill}` (full pill — capsule-shaped), padding 11px × 22px. The full-pill radius IS the brand action signal. +- Active state: `{component.button-primary-active}` — `transform: scale(0.95)` (the system-wide micro-interaction). +- Focus state: `{component.button-primary-focus}` — 2px solid `{colors.primary-focus}` outline. + +**`button-secondary-pill`** — Used as the second CTA when two blue pills appear together ("Learn more" / "Buy"). Background transparent, text `{colors.primary}`, 1px solid `{colors.primary}` border, rounded `{rounded.pill}`, padding 11px × 22px. Reads as a "ghost pill." + +**`button-dark-utility`** — Global nav actions (Sign In, Bag, language selector). Background `{colors.ink}` (#1d1d1f), text `{colors.on-dark}` in `{typography.button-utility}` (14px / 400 / -0.224px tracking), rounded `{rounded.sm}` (8px), padding 8px × 15px. Active state shrinks via `transform: scale(0.95)`. + +**`button-pearl-capsule`** — Product-card secondary button. Background `{colors.surface-pearl}` (#fafafc), text `{colors.ink-muted-80}` in `{typography.caption}` (14px), 3px solid `{colors.divider-soft}` border (functions as a soft ring rather than a visible line), rounded `{rounded.md}` (11px), padding 8px × 14px. + +**`button-store-hero`** — A larger primary CTA used on store hero surfaces. Same Action Blue + Paper White as `{component.button-primary}`, but with `{typography.button-large}` (18px / 300 — note the rare weight 300) and slightly more padding (14px × 28px). Used sparingly on the store landing. + +**`button-icon-circular`** — Floats over photography. 44 × 44px, background `{colors.surface-chip-translucent}` at ~64% alpha, icon in `{colors.ink}`, rounded `{rounded.full}`. Used for carousel controls, close buttons, and in-image controls (product image thumbnails on the iPhone buy page). + +**`text-link`** — Inline body links in `{colors.primary}` (Action Blue). Underlined or non-underlined per context. + +**`text-link-on-dark`** — Inline body links on dark tiles in `{colors.primary-on-dark}` (Sky Link Blue #2997ff) — Action Blue would disappear against `{colors.surface-tile-1}`. + +### Cards & Containers + +**`product-tile-light`** — Full-bleed light tile. Background `{colors.canvas}` (white), text `{colors.ink}`, rounded `{rounded.none}` (0 — tiles touch edges), vertical padding `{spacing.section}` (80px). Centered stack: product name in `{typography.display-lg}` (40px / 600) → one-line tagline in `{typography.lead}` (28px / 400) → two `{component.button-primary}` CTAs ("Learn more" / "Buy") → product render resting on the surface with the system shadow. + +**`product-tile-parchment`** — Same as `{component.product-tile-light}` but on `{colors.canvas-parchment}` (#f5f5f7). Used to break two consecutive white tiles. + +**`product-tile-dark`** — Full-bleed dark tile. Background `{colors.surface-tile-1}` (#272729), text `{colors.on-dark}`, rounded `{rounded.none}`, vertical padding `{spacing.section}` (80px). Same content stack as the light tile but with `{component.text-link-on-dark}` for inline copy and `{component.button-primary}` (Action Blue still works on the dark surface). Used on the homepage product grid as the alternating dark band. + +**`product-tile-dark-2`** — Variant on `{colors.surface-tile-2}` (#2a2a2c). Used where a dark tile sits directly above or below `{component.product-tile-dark}` to create the faintest separation through micro-step lightness change. + +**`product-tile-dark-3`** — Variant on `{colors.surface-tile-3}` (#252527). Used at the bottom of the stack and in embedded video/player frames. + +**`store-utility-card`** — Used in store grid and accessories grid. Background `{colors.canvas}` (white), 1px solid `{colors.hairline}` border, rounded `{rounded.lg}` (18px), padding `{spacing.lg}` (24px). Top: product image (1:1 crop with `{rounded.sm}` (8px) inner image radius). Below: product name in `{typography.body-strong}` (17px / 600), price in `{typography.body}` (17px / 400), and a `{component.text-link}` ("Buy" or "Learn more"). No shadow by default; product render itself carries the system product-shadow. + +**`configurator-option-chip`** — Pill-shaped tappable cell used in the iPhone 17 Pro buy page. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.caption}`, rounded `{rounded.pill}`, padding 12px × 16px. Contains a small product thumbnail + label + price delta. Arranged in a grid of 4–5 options per row. + +**`configurator-option-chip-selected`** — Selected state. Border upgrades to 2px solid `{colors.primary-focus}`. Same shape, same content. + +**`environment-quote-card`** — A photographic-canvas hero specific to the environment page. Dark photographic backdrop (mountain vista at dawn) with `{colors.surface-tile-1}` as the fallback color, centered white-text headline in `{typography.display-lg}` (40px), small green "Apple 2030" pictographic logo above the headline, single `{component.button-primary}` below. Padding `{spacing.section}` (80px). + +**`floating-sticky-bar`** — Floats at the bottom of the viewport on the iPhone 17 Pro buy page during scroll. Background `{colors.canvas-parchment}` at 80% opacity with `backdrop-filter: blur(N)`, height 64px, padding 12px × 32px. Left: running price total in `{typography.body}`. Right: `{component.button-primary}` ("Add to Bag"). + +### Inputs & Forms + +**`search-input`** — The accessories search input. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.body}` (17px), 1px solid `rgba(0, 0, 0, 0.08)` border, rounded `{rounded.pill}` (full pill — search is also pill-shaped, matching the CTA grammar), padding 12px × 20px, height 44px. Leading icon: search glyph at 14px, muted tint. + +Error and validation states were not surfaced in the analyzed pages. + +### Footer + +**`footer`** — Background `{colors.canvas-parchment}` (#f5f5f7), text `{colors.ink-muted-80}`. Link columns in `{typography.dense-link}` (17px / 400 / 2.41 line-height — the relaxed leading is what makes the dense columns scannable). Column headings in `{typography.caption-strong}` (14px / 600). Legal row at the very bottom in `{typography.fine-print}` (12px / 400) with `{colors.ink-muted-48}` text. Vertical padding 64px. + +## Do's and Don'ts + +### Do +- Use `{colors.primary}` (Action Blue #0066cc) for every interactive element — links, pill CTAs, focus signals — and nothing else. The single accent is non-negotiable. +- Set headlines in `{typography.hero-display}` or `{typography.display-lg}` with negative letter-spacing (`-0.28 → -0.374px`) to get the signature "Apple tight" cadence. +- Run body copy at `{typography.body}` (17px / 400 / 1.47 / -0.374px) — not 16px. The extra pixel defines the brand's reading pace. +- Alternate `{component.product-tile-light}` (or parchment) and `{component.product-tile-dark}` for full-bleed section rhythm. The color change IS the divider. +- Reserve `{rounded.pill}` for the primary blue CTA and any other element that should read as an "action" (configurator chips, search input, sticky bar CTA). +- Apply the single product-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) only to product renders resting on a surface — never on cards, buttons, or text. +- Use `transform: scale(0.95)` as the active/press state on every button — it's the system-wide micro-interaction. +- Keep the global nav `{colors.surface-black}` (true black) — it's the only place pure black appears on most pages. + +### Don't +- Don't introduce a second accent color; every "click me" signal is `{colors.primary}` (Action Blue). +- Don't add shadows to cards, buttons, or text — shadow is reserved for product imagery. +- Don't use gradients as decorative backgrounds; atmosphere comes from photography. +- Don't set body copy at weight 500 — Apple's ladder is 300 / 400 / 600 / 700, with 500 deliberately absent. Body is always 400; strong inline is 600; display is 600. +- Don't round full-bleed tiles — tiles are rectangular and edge-to-edge; the color change is the divider. +- Don't tighten line-height below 1.47 for body copy — the editorial leading is part of the brand. +- Don't mix radii grammars — use `{rounded.sm}` for compact utility, `{rounded.lg}` for utility cards, `{rounded.pill}` for pills, and nothing in between (except the rare `{rounded.md}` Pearl Button). +- Don't use `{colors.primary-on-dark}` (Sky Link Blue) on light surfaces — it's the dark-tile-only variant. Action Blue is for light surfaces. + +## Responsive Behavior + +### Breakpoints + +| Name | Width | Key Changes | +|---|---|---| +| Small phone | ≤ 419px | Single-column tiles; sub-nav collapses to category name + primary CTA only; hero typography drops to 28px | +| Phone | 420–640px | Single-column stack; product renders scale to 80% of tile width; hero h1 drops to 34px | +| Large phone | 641–735px | Tiles transition to tighter padding (48px vertical vs 80px); fine-print wraps | +| Tablet portrait | 736–833px | Global nav collapses to hamburger; sub-nav hides category chips, keeps primary CTA | +| Tablet landscape | 834–1023px | Global nav returns fully expanded; 3-column utility grids become 2-column | +| Small desktop | 1024–1068px | Product tiles use 2/3 width with margin gutters; hero h1 stays at 40px | +| Desktop | 1069–1440px | Full layout; 4–5 column store grids; 1440px content max | +| Wide desktop | ≥ 1441px | Content locks at 1440px, margins absorb extra width | + +The structural breakpoints that matter for agents: 1440px (content lock), 1068px (small-desktop), 833px (tablet landscape switch), 734px (tablet portrait), 640px (phone), 480px (small phone). + +### Touch Targets +- Minimum 44 × 44px. `{component.button-primary}` lands at ~44 × 100px (with the full-pill radius making the visible hit area more generous than the label suggests). +- `{component.button-icon-circular}` is exactly 44 × 44px. +- Global nav utility links are smaller (~32 × 80px) — they deliberately sit at a tighter target because they're precision desktop actions, and the mobile hamburger replaces them at ≤ 833px. + +### Collapsing Strategy +- **Global nav**: full horizontal link row on desktop → collapses to Apple logo + hamburger + bag icon at 834px and below. +- **Sub-nav**: category name + inline links + primary CTA → category name + primary CTA only at mobile; inline links move into a hamburger tray. +- **Product tiles**: stack from 2-column to 1-column at 834px; vertical padding tightens from 80px → 48px at small-phone. +- **Utility grids** (store, accessories): 5-col → 4-col (1440px) → 3-col (1068px) → 2-col (834px) → 1-col (640px). +- **Hero typography**: `{typography.hero-display}` (56px) → `{typography.display-lg}` (40px) at 1068px → 34px at 640px → 28px at 419px. + +### Image Behavior +- All product imagery uses responsive `srcset` with breakpoint-matched crops. +- Hero photography may switch art direction at mobile (e.g., the environment page's vista crops to a taller aspect ratio on mobile, framing the subject differently). +- Product renders maintain their 1:1 or 4:3 aspect ratios across breakpoints; only scale changes. +- Lazy-loading is default; the above-fold hero loads eagerly. + +## Iteration Guide + +1. Focus on ONE component at a time. Reference its YAML key directly (`{component.product-tile-dark}`, `{component.search-input}`). +2. Variants of an existing component (`-active`, `-focus`, `-2`, `-3`) live as separate entries in `components:`. +3. Use `{token.refs}` everywhere — never inline hex. +4. Never document hover. Default and Active/Pressed states only. +5. Display headlines stay SF Pro Display 600 with negative letter-spacing. Body stays SF Pro Text 400 at 17px. The boundary is unbreakable. +6. The single drop-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) is reserved for product photography only. +7. When in doubt about emphasis: alternate surface (light → dark tile) before adding chrome. + +## Known Gaps + +- Form validation and error states were not surfaced on the analyzed pages; only the neutral search input is documented. +- The homepage's embedded video/player frame uses `{colors.surface-black}`; interior player controls are not documented (they're a platform widget, not a web-design token). +- Some component imagery is dynamic (rotating product hero) and its specific copy varies per surface — component specs name the structure, not the rotating content. +- Dark-mode counterparts for store and accessories utility cards were not surfaced on the analyzed pages; the system documented is the daytime/light-dominant variant Apple ships by default. +- Atmospheric photography (environment page mountain vista) is a content asset, not a design token; the documented `{component.environment-quote-card}` describes the structural surface only. +- The exact backdrop-filter blur radius on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` is platform-dependent; production CSS uses `saturate(180%) blur(20px)` as a typical baseline but the value isn't formalized as a token. diff --git a/DESIGN_CLAUDE.md b/DESIGN_CLAUDE.md new file mode 100644 index 000000000..d091764f0 --- /dev/null +++ b/DESIGN_CLAUDE.md @@ -0,0 +1,289 @@ +## Overview + +Claude.com is the warmest, most editorial interface in the AI-product category. The base atmosphere is a **tinted cream canvas** (`{colors.canvas}` — #faf9f5) — distinctly warm, deliberately not the cool gray-white that every other AI brand uses. Headlines run a **slab-serif display** ("Copernicus" / Tiempos Headline) at weight 400 with negative letter-spacing, paired with **StyreneB / Inter** body sans. The combination feels like a literary publication, not a SaaS marketing page. + +Brand voltage comes from the **cream + coral pairing** — coral (`{colors.primary}` — #cc785c) is the signature Anthropic accent, used on every primary CTA, on the brand wordmark, and on full-bleed callout cards. The coral is warm, slightly muted, never cyan/blue — a deliberate counter-positioning against OpenAI's cool slate, Google's saturated blue, and Microsoft's corporate cyan. + +The system has three surface modes that alternate page-by-page: +1. **Cream canvas** (`{colors.canvas}`) — default body floor +2. **Light cream cards** (`{colors.surface-card}`) — feature card backgrounds +3. **Dark navy product surfaces** (`{colors.surface-dark}`) — code editor mockups, model showcase cards, pre-footer CTAs, footer itself + +The dark surfaces are where Claude shows its product chrome — code blocks, terminal output, model comparison tables, agentic-flow diagrams. The cream-to-dark contrast is the page's pacing rhythm. + +**Key Characteristics:** +- Warm cream canvas (`{colors.canvas}` — #faf9f5) with dark warm-ink text (`{colors.ink}` — #141413). The brand's defining color choice. +- Coral primary CTA (`{colors.primary}` — #cc785c). Used scarcely on individual buttons, generously on full-bleed coral callout cards. +- Slab-serif display headlines via Copernicus / Tiempos Headline at weight 400 with negative letter-spacing. Pairs with humanist sans body for a literary editorial voice. +- Dark navy product mockup cards (`{colors.surface-dark}` — #181715) carrying code blocks, terminal panels, model comparison data — the brand shows the product chrome at scale rather than abstract marketing illustrations. +- Light cream feature cards (`{colors.surface-card}` — #efe9de) — slightly darker than canvas, used for content-driven feature explanations. +- Anthropic radial-spike mark — a small black asterisk-like glyph (4-spoke radial) — appears as the brand wordmark prefix and as a content marker. +- Border radius is hierarchical: `{rounded.md}` (8px) for buttons + inputs, `{rounded.lg}` (12px) for content + product cards, `{rounded.xl}` (16px) for the hero illustration container, `{rounded.pill}` for badges. +- Section rhythm `{spacing.section}` (96px) — modern-SaaS standard. Internal card padding stays generous at `{spacing.xl}` (32px). + +## Colors + +### Brand & Accent +- **Coral / Primary** (`{colors.primary}` — #cc785c): The signature Anthropic warm coral. Used on every primary CTA background, on full-bleed coral callout cards, on the brand wordmark accent. The most-recognized Anthropic color outside of the spike-mark logo. +- **Coral Active** (`{colors.primary-active}` — #a9583e): The press / hover-darker variant. +- **Coral Disabled** (`{colors.primary-disabled}` — #e6dfd8): A desaturated cream-tinted disabled state. +- **Accent Teal** (`{colors.accent-teal}` — #5db8a6): Used sparingly on secondary product surfaces (terminal status indicators, "active connection" dots in connectors page). +- **Accent Amber** (`{colors.accent-amber}` — #e8a55a): A small companion warm-tone used on category badges and inline highlights. + +### Surface +- **Canvas** (`{colors.canvas}` — #faf9f5): The default page floor. Tinted cream — warm, deliberately not pure white. +- **Surface Soft** (`{colors.surface-soft}` — #f5f0e8): Section dividers, very-soft band backgrounds. +- **Surface Card** (`{colors.surface-card}` — #efe9de): Feature cards, content cards. One step darker than canvas. +- **Surface Cream Strong** (`{colors.surface-cream-strong}` — #e8e0d2): A strongest-cream variant used on selected category tabs and emphasized section bands. +- **Surface Dark** (`{colors.surface-dark}` — #181715): Code editor mockups, model showcase cards, footer. The dominant dark surface. +- **Surface Dark Elevated** (`{colors.surface-dark-elevated}` — #252320): Elevated cards inside dark bands (settings panels in mockups). +- **Surface Dark Soft** (`{colors.surface-dark-soft}` — #1f1e1b): Slightly lighter dark, used for code block backgrounds inside larger dark cards. +- **Hairline** (`{colors.hairline}` — #e6dfd8): The 1px border tone on cream surfaces. Same hex as `{colors.primary-disabled}` — borders feel like one elevation step rather than ink lines. +- **Hairline Soft** (`{colors.hairline-soft}` — #ebe6df): Barely-visible divider used inside the same band. + +### Text +- **Ink** (`{colors.ink}` — #141413): All headlines and primary text. Warm dark, slightly off-pure-black. +- **Body Strong** (`{colors.body-strong}` — #252523): Emphasized paragraphs, lead text. +- **Body** (`{colors.body}` — #3d3d3a): Default running-text color. +- **Muted** (`{colors.muted}` — #6c6a64): Sub-headings, breadcrumbs, footer-adjacent secondary text. +- **Muted Soft** (`{colors.muted-soft}` — #8e8b82): Captions, fine-print, copyright lines. +- **On Primary** (`{colors.on-primary}` — #ffffff): Text on coral buttons. +- **On Dark** (`{colors.on-dark}` — #faf9f5): Cream-tinted white used on dark surfaces (echoes the canvas tone). +- **On Dark Soft** (`{colors.on-dark-soft}` — #a09d96): Footer body text, secondary labels in dark mockups. + +### Semantic +- **Success** (`{colors.success}` — #5db872): Green status dots, "available" indicators. +- **Warning** (`{colors.warning}` — #d4a017): Warning callouts (rare on marketing surfaces). +- **Error** (`{colors.error}` — #c64545): Validation errors. + +## Typography + +### Font Family +The system runs **Copernicus** (or **Tiempos Headline** as substitute) as the slab-serif display face for headlines, and **StyreneB** (or **Inter** as substitute) as the humanist sans for body, navigation, and UI labels. **JetBrains Mono** handles code blocks. The fallback stack walks `Tiempos Headline, Garamond, "Times New Roman", serif` for display and `Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` for body. + +The display/body split is editorial: +- Copernicus serif (weight 400, negative tracking) → h1, h2, h3, hero display +- StyreneB sans (weight 400-500) → body, navigation, buttons, captions, labels +- JetBrains Mono → all code blocks and terminal text + +### Hierarchy + +| Token | Size | Weight | Line Height | Letter Spacing | Use | +|---|---|---|---|---|---| +| `{typography.display-xl}` | 64px | 400 | 1.05 | -1.5px | Homepage h1 ("Meet your thinking partner") — Copernicus serif | +| `{typography.display-lg}` | 48px | 400 | 1.1 | -1px | Section heads — Copernicus | +| `{typography.display-md}` | 36px | 400 | 1.15 | -0.5px | Sub-section heads, model names — Copernicus | +| `{typography.display-sm}` | 28px | 400 | 1.2 | -0.3px | Pricing tier names, callout headlines — Copernicus | +| `{typography.title-lg}` | 22px | 500 | 1.3 | 0 | Pricing plan size labels — StyreneB | +| `{typography.title-md}` | 18px | 500 | 1.4 | 0 | Feature card titles, intro paragraphs | +| `{typography.title-sm}` | 16px | 500 | 1.4 | 0 | Connector tile titles, list labels | +| `{typography.body-md}` | 16px | 400 | 1.55 | 0 | Default running-text — StyreneB | +| `{typography.body-sm}` | 14px | 400 | 1.55 | 0 | Footer body, fine-print | +| `{typography.caption}` | 13px | 500 | 1.4 | 0 | Badge labels, captions | +| `{typography.caption-uppercase}` | 12px | 500 | 1.4 | 1.5px | Category tags, "NEW" badges | +| `{typography.code}` | 14px | 400 | 1.6 | 0 | Code blocks — JetBrains Mono | +| `{typography.button}` | 14px | 500 | 1.0 | 0 | Standard button labels | +| `{typography.nav-link}` | 14px | 500 | 1.4 | 0 | Top-nav menu items | + +### Principles +Display sizes use weight 400 (regular), never bold. Negative letter-spacing (-0.3 to -1.5px) is essential — Copernicus without it reads as off-brand. The serif character is what gives Anthropic its literary, considered voice; switching to a sans-serif display would make Claude feel like every other AI tool. + +Body type stays at weight 400 for paragraphs, weight 500 for labels and emphasized phrases. The sans body is humanist (StyreneB) — never geometric. Inter is an acceptable substitute because of its similar humanist proportions; Helvetica or Arial would be too neutral and break the warm-editorial feel. + +### Note on Font Substitutes +If Copernicus / Tiempos Headline is unavailable, **Cormorant Garamond** at weight 500 with -0.02em letter-spacing is the closest open-source approximation. **EB Garamond** is a fallback. For StyreneB, **Inter** is the closest match — both are humanist sans designed for screen reading. **Söhne** is another close alternative if licensed. + +## Layout + +### Spacing System +- **Base unit:** 4px. +- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 96px. +- **Section padding:** `{spacing.section}` (96px) — modern-SaaS rhythm. +- **Card internal padding:** `{spacing.xl}` (32px) for feature cards, pricing tier cards, model comparison cards; `{spacing.lg}` (24px) for code-window cards and connector tiles. +- **Callout / CTA bands:** `{spacing.xxl}` (48px) inside coral callout cards; 64px inside the larger dark CTA band. + +### Grid & Container +- **Max content width:** ~1200px centered. +- **Editorial body:** Single 12-column grid; hero often uses 6/6 split (h1 left, illustration right). +- **Feature card grids:** 3-up at desktop, 2-up at tablet, 1-up at mobile. +- **Connector tile grids:** 4-up or 6-up at desktop, 2-up at tablet, 1-up at mobile. +- **Pricing grid:** 3-up at desktop (Free / Pro / Team / Enterprise often), 1-up at mobile. + +### Whitespace Philosophy +The cream canvas + serif display + generous internal padding create an editorial pacing — Claude reads like a long-form magazine column rather than a marketing template. Whitespace between bands stays uniform at 96px; whitespace inside cards is generous (32px), letting type breathe. + +## Elevation & Depth + +| Level | Treatment | Use | +|---|---|---| +| Flat | No shadow, no border | Body sections, top nav, hero bands | +| Soft hairline | 1px `{colors.hairline}` border | Inputs, sub-nav, occasionally on cards | +| Cream card | `{colors.surface-card}` background — no shadow | Feature cards, content cards | +| Dark surface card | `{colors.surface-dark}` background — no shadow | Code editor mockups, model showcase cards | +| Subtle drop shadow | Faint shadow at low alpha | Hover-elevated states (the system uses `0 1px 3px rgba(20,20,19,0.08)` rarely) | + +The elevation philosophy is **color-block first, shadow rare**. Most depth comes from the cream-vs-dark surface contrast. Shadows are minimal. The dark surface mockups have their own internal product chrome (code editor scrollbars, line numbers, syntax highlighting) which adds detail without needing external shadows. + +### Decorative Depth +- The Anthropic spike-mark glyph (4-spoke radial asterisk) appears as a small black mark in the brand wordmark and inline as a content marker. +- Code editor mockups carry their own internal depth: syntax-highlighted text in muted blues / oranges / grays, line numbers in `{colors.muted-soft}`, status bars at the bottom in `{colors.surface-dark-elevated}`. +- Some hero illustrations use simple line-art with coral and dark-navy strokes on cream — minimal, hand-drawn-feeling, never photorealistic. + +## Shapes + +### Border Radius Scale + +| Token | Value | Use | +|---|---|---| +| `{rounded.xs}` | 4px | Reserved for badge accents and tiny dropdowns | +| `{rounded.sm}` | 6px | Small inline buttons, dropdown items | +| `{rounded.md}` | 8px | Standard CTA buttons, text inputs, category tabs | +| `{rounded.lg}` | 12px | Content cards (feature, pricing, code-window, model-comparison) | +| `{rounded.xl}` | 16px | Hero illustration container, the larger marquee components | +| `{rounded.pill}` | 9999px | Badge pills, "NEW" tags | +| `{rounded.full}` | 9999px / 50% | Avatar substitutes, icon buttons | + +### Photography & Illustrations +Claude's hero rarely uses photography. Instead it uses: +- Simple line-art illustrations with coral + dark-navy strokes on the cream canvas +- Code editor mockups (the dominant "hero" treatment on developer-focused pages) +- Terminal output mockups with monospace text on dark +- Model comparison cards (Opus / Sonnet / Haiku) with abstract geometric thumbnails + +When photography is used (rare — mostly testimonials), avatars crop to perfect circles at 40px diameter. + +## Components + +### Top Navigation + +**`top-nav`** — Cream nav bar pinned to the top of every page. 64px tall, `{colors.canvas}` background. Carries the Anthropic spike-mark + "Claude" wordmark at left, primary horizontal menu (Product, Solutions, Use Cases, Pricing, Research, Company) center-left, right-side cluster with "Sign in" text-link, "Try Claude" `{component.button-primary}` (coral). Menu items in `{typography.nav-link}` (StyreneB 14px / 500). + +### Buttons + +**`button-primary`** — The signature coral CTA. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), type `{typography.button}` (StyreneB 14px / 500), padding 12px × 20px, height 40px, rounded `{rounded.md}` (8px). Active state `button-primary-active` darkens to `{colors.primary-active}` (#a9583e). + +**`button-secondary`** — Cream button with hairline outline. Background `{colors.canvas}`, text `{colors.ink}`, 1px hairline border, same padding + height + radius as primary. + +**`button-secondary-on-dark`** — Used over `{colors.surface-dark}` cards. Background `{colors.surface-dark-elevated}` (#252320), text `{colors.on-dark}`. Stays dark — the system never inverts to a light secondary on dark surfaces. + +**`button-text-link`** — Inline text button, no background. Used for "Sign in" in the top nav and inline CTA links. + +**`button-icon-circular`** — 36px circular icon button. Background `{colors.canvas}`, hairline border, ink-color icon. Used for carousel arrows, share, "view more". + +**`text-link`** — Inline body links in `{colors.primary}` (the coral). Underlined on press; the coral inline link is one of the system's most distinctive small details. + +### Cards & Containers + +**`hero-band`** — Cream-canvas hero with a 6-6 grid: h1 + sub-headline + button row on the left, hero illustration card or product mockup card on the right. Vertical padding `{spacing.section}` (96px). + +**`hero-illustration-card`** — A larger card holding the hero's right-side artifact — sometimes a coral-stroke line illustration on cream background, sometimes a dark code editor mockup. Background `{colors.canvas}` or `{colors.surface-dark}` depending on context, rounded `{rounded.xl}` (16px). + +**`feature-card`** — Used in 3-up feature grids. Background `{colors.surface-card}` (#efe9de — slightly darker cream), rounded `{rounded.lg}` (12px), internal padding `{spacing.xl}` (32px). Carries a small icon at top, an `{typography.title-md}` headline, and a body description in `{typography.body-md}`. + +**`product-mockup-card-dark`** — Dark navy card showing actual Claude product chrome (chat interface, code editor, agent controls). Background `{colors.surface-dark}`, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries text labels in `{colors.on-dark}` and product UI fragments below. + +**`code-window-card`** — A specialized dark card showing a code editor with line numbers, syntax-highlighted code in `{typography.code}` (JetBrains Mono), and sometimes a "Run" button or terminal output panel below. Background `{colors.surface-dark}` with `{colors.surface-dark-soft}` for the inner code block, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). The signature visual element of Claude Code product pages. + +**`model-comparison-card`** — Used on the homepage's "Which problem are you up against?" section comparing Opus / Sonnet / Haiku. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries the model name, a short capability blurb, and a `{component.text-link}` to learn more. + +**`pricing-tier-card`** — Standard tier card. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding `{spacing.xl}` (32px). Carries the plan name in `{typography.title-lg}` (StyreneB), price in `{typography.display-sm}` (Copernicus serif!), feature checklist in `{typography.body-md}`, and a `{component.button-primary}` at the bottom. + +**`pricing-tier-card-featured`** — The featured tier (typically "Pro" or "Team"). Background flips to `{colors.surface-dark}`, text inverts to `{colors.on-dark}`. The dark surface IS the featured-tier signal. + +**`callout-card-coral`** — A full-bleed coral card carrying a major call-to-action. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), rounded `{rounded.lg}`, padding `{spacing.xxl}` (48px). The coral surface IS the voltage; the CTA inside uses an inverted button style (cream/canvas button on coral). + +**`connector-tile`** — Used on the connectors page's integration grid. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding 20px. Each tile carries a logo at top, a `{typography.title-sm}` connector name, and a short description. + +### Inputs & Forms + +**`text-input`** — Standard text input. Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, rounded `{rounded.md}` (8px), padding 10px × 14px, height 40px. 1px hairline border in `{colors.hairline}`. + +**`text-input-focused`** — Focus state. Border thickens or shifts to `{colors.primary}` (coral) for emphasis. Carries a 3px coral-at-15%-alpha outer ring. + +**`cookie-consent-card`** — Bottom-right floating dark cookie banner. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). One of the few places dark surface appears at small scale on cream pages. + +### Tags / Badges + +**`badge-pill`** — Small pill label used for category tags. Background `{colors.surface-card}`, text `{colors.ink}`, type `{typography.caption}` (13px / 500), rounded `{rounded.pill}`, padding 4px × 12px. + +**`badge-coral`** — Coral-fill badge for "NEW", "BETA", featured highlights. Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.caption-uppercase}` (12px / 500 / 1.5px tracking), rounded `{rounded.pill}`, padding 4px × 12px. + +### Tab / Filter + +**`category-tab`** + **`category-tab-active`** — Used in sub-nav rows on solutions / connectors pages. Inactive: transparent background, `{colors.muted}` text. Active: `{colors.surface-card}` background, `{colors.ink}` text. Padding 8px × 14px, rounded `{rounded.md}`. + +### CTA / Footer + +**`cta-band-coral`** — A pre-footer "Try Claude" CTA card. Full-width coral fill, white type, rounded `{rounded.lg}`, padding 64px. Carries an h2 in `{typography.display-sm}` (still serif!), a sub-line, and a cream-button CTA. + +**`cta-band-dark`** — Alternative pre-footer band on developer-focused pages. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding 64px. Often pairs with a code-window card. + +**`footer`** — Dark navy footer that closes every page. Background `{colors.surface-dark}` (#181715), text `{colors.on-dark-soft}`. 4-column link list at desktop covering Product / Company / Resources / Legal. Vertical padding 64px. The Anthropic spike-mark + "Anthropic" wordmark sits at the top in `{colors.on-dark}`. The footer never inverts. + +## Do's and Don'ts + +### Do +- Anchor every page on the cream canvas. Pure white reads as "any other AI tool"; the warm tint is the brand differentiator. +- Use Copernicus serif for every display headline. Pair with StyreneB sans body. Negative letter-spacing on display sizes is non-negotiable. +- Reserve `{colors.primary}` (coral) for primary CTAs and full-bleed `{component.callout-card-coral}` moments. Don't paint accent moments coral elsewhere. +- Use `{component.product-mockup-card-dark}` and `{component.code-window-card}` to show actual Claude product chrome. Don't paint marketing illustrations of code when you can show real code. +- Pair `{component.feature-card}` (cream) with `{component.product-mockup-card-dark}` (navy) in alternating bands. The cream-to-dark rhythm is the brand's pacing mechanism. +- Use the Anthropic spike-mark glyph as the brand wordmark prefix. Never invert the mark to white-on-dark within the wordmark itself. +- Apply `{spacing.section}` (96px) between major bands. + +### Don't +- Don't use cool grays or pure white for canvas. Cream is the brand. +- Don't bold serif display weight. Copernicus at 700 reads as bombastic; the system stays at 400. +- Don't use cool blue or saturated cyan as a brand accent. The coral is the brand voltage. +- Don't put coral everywhere. The coral is scarce on individual elements and generous only on full-bleed coral callout cards. +- Don't use Inter for display headlines. The serif character is the brand voice. +- Don't repeat the same surface mode in two consecutive bands. The pacing alternates: cream → cream-card → dark-mockup → cream → coral-callout → dark-footer. +- Don't add hover state styling beyond what the system already encodes — primary darkens on press; nothing else changes. + +## Responsive Behavior + +### Breakpoints + +| Name | Width | Key Changes | +|---|---|---| +| Mobile | < 768px | Hamburger nav; hero h1 64→32px; hero-illustration-card stacks below content; feature grids 1-up; connector tiles 2-up; pricing 1-up; footer 4 cols → 1 | +| Tablet | 768–1024px | Top nav stays horizontal but tightens; feature cards 2-up; connector tiles 3-up; pricing 2-up | +| Desktop | 1024–1440px | Full top-nav with all menu items; 3-up feature cards; 4-up or 6-up connector tiles; 3-up pricing tiers | +| Wide | > 1440px | Same as desktop with more outer breathing room; max content width caps at 1200px | + +### Touch Targets +- `{component.button-primary}` at minimum 40 × 40px. +- `{component.button-icon-circular}` at exactly 36 × 36 — slightly under WCAG 44 but visually centered. +- `{component.text-input}` height is 40px. +- Connector tile entire card area is tappable; effective tap area >> 44px. + +### Collapsing Strategy +- Top nav collapses to hamburger at < 768px; menu opens as a full-screen cream sheet. +- Hero band's 6-6 grid collapses to single-column on mobile — h1 + sub-head + buttons first, then the illustration / mockup card below. +- Feature grids reduce columns rather than scaling cards down. +- Pricing tier cards collapse 4 → 2 → 1; featured-tier dark surface stays visually distinct at every breakpoint. +- Code-window cards retain code legibility at every breakpoint by allowing horizontal scroll within the card rather than wrapping code lines. + +### Image Behavior +- Code blocks inside dark mockups stay at fixed font-size; horizontal scroll on mobile rather than wrapping. +- Hero illustrations scale proportionally; line-art strokes thin slightly on mobile. +- Avatar photos in testimonials crop to circles at every breakpoint. + +## Iteration Guide + +1. Focus on ONE component at a time. Reference its YAML key (`{component.feature-card}`, `{component.code-window-card}`). +2. Variants of an existing component (`-active`, `-disabled`, `-focused`) live as separate entries in `components:`. +3. Use `{token.refs}` everywhere — never inline hex. +4. Never document hover. Default and Active/Pressed states only. +5. Display headlines stay Copernicus serif 400 with negative tracking. Body stays StyreneB / Inter 400. The split is unbreakable. +6. Cream + coral + dark navy is the trinity. Don't introduce a fourth surface tone (no purple cards, no green sections). +7. When in doubt about emphasis: bigger Copernicus serif before bolder weight. + +## Known Gaps + +- Copernicus and StyreneB are licensed Anthropic typefaces and not available as public web fonts. Substitutes (Tiempos Headline / Cormorant Garamond / EB Garamond for serif; Inter / Söhne for sans) are documented in the typography section. +- The Anthropic radial-spike-mark is a brand glyph rendered as inline SVG; it's not formalized as a system token here. Treat it as a logo asset. +- Animation and transition timings (chat message reveal, code block typewriter effect on the homepage, agentic-flow diagram animations) are not in scope. +- Form validation states beyond `{component.text-input-focused}` are not extracted — error / success states would need a sign-up or feedback flow to confirm. +- The actual Claude product surface (claude.ai chat interface) shares some tokens with the marketing site but adds many product-specific components (chat bubbles, message tools, file upload chips, conversation history sidebar) that are out of scope for this marketing-surface document. +- The "agent" / "computer use" demo cards on certain pages display animated Claude controlling a browser — the static screenshot doesn't fully capture the animation chrome. diff --git a/DESIGN_NOTION.md b/DESIGN_NOTION.md new file mode 100644 index 000000000..ab567b982 --- /dev/null +++ b/DESIGN_NOTION.md @@ -0,0 +1,376 @@ +## Overview + +Notion presents itself as the all-in-one workspace through a confident, illustration-rich brand voice. The homepage opens with **"Meet the night shift."** rendered centered over a deep navy hero band ({colors.brand-navy}), decorated with brand-colored sticky-note dots and mesh wire illustrations scattered around the headline. The signature **purple pill primary CTA** ({colors.primary}) "Get Notion free" sits at the visual center, paired with an outlined "Request a demo" secondary. Below the buttons, a real Notion workspace UI mockup card (the "Ramp HQ" kanban board) breaks out of the hero band with a deep diffuse drop shadow. + +Below the hero, the page cycles through a distinctive sequence of feature sections: a dense sticky-note "Keep work moving 24/7" panel with red/blue/green/purple/teal status icons; a **bold yellow** ({colors.card-tint-yellow-bold}) "Ask your on-demand assistants" banner card flanked by orange/rose/mint pastel feature tiles showing assistant UI mockups; and a "Bring all your work together" 3-column grid with brand-colored mockups (sky-blue tutorial card, light Notion calendar, brown/rust testimonial slate). The pricing page renders 4 tiers (Free / Plus / Business / Enterprise) horizontally with one tier featured (purple-bordered) and a dense feature comparison table running below. + +The system uses a Notion-Sans typeface (Inter-based) across every UI surface — humanist-geometric character that pairs naturally with the colorful illustrations. Buttons are `{rounded.md}` (8px) rectangles, NOT pills — distinguishing Notion's sober rectangular geometry from competitors that use pills universally. Cards use `{rounded.lg}` (12px) consistently. + +**Key Characteristics:** +- Deep navy hero band ({colors.brand-navy}) with scattered sticky-note dots + mesh wire decorative illustrations +- **Signature purple pill** ({colors.primary}) primary CTA — Notion's recognizable "Get Notion free" button color +- Real Notion workspace UI mockup card embedded in the hero with deep drop shadow +- Bold yellow feature banner ({colors.card-tint-yellow-bold}) for high-emphasis content sections +- Pastel feature card palette (peach, rose, mint, lavender, sky, yellow) echoing the live product database properties +- Notion-Sans (Inter-based) across every UI surface +- 8px-rounded buttons (NOT pills), 12px-rounded cards — sober editorial geometry +- 4-tier pricing comparison with dense feature table +- Centered hero layout (different from the left-aligned norm of most B2B SaaS) + +## Colors + +> Source pages: notion.com/ (homepage), /enterprise, /product/ai, /product/agents, /startups, /pricing. Token coverage was identical across all six pages. + +### Brand & Primary +- **Notion Purple** ({colors.primary}): Signature primary CTA color — the unmistakable "Get Notion free" pill button. Reserved for the dominant CTA only. +- **Purple Pressed** ({colors.primary-pressed}): Pressed-state variant +- **Purple Deep** ({colors.primary-deep}): Deeper variant for emphasis +- **Brand Navy** ({colors.brand-navy}): Hero band background — deep navy +- **Brand Navy Deep** ({colors.brand-navy-deep}): Deeper navy for promo banner +- **Brand Navy Mid** ({colors.brand-navy-mid}): Mid-spectrum navy +- **Link Blue** ({colors.link-blue}): Inline text link blue (NOT primary CTA) +- **Link Blue Pressed** ({colors.link-blue-pressed}): Pressed-state link blue + +### Brand Color Spectrum (echoes live product database properties) +- **Brand Pink** ({colors.brand-pink}): Pink accent +- **Brand Pink Deep** ({colors.brand-pink-deep}): Deeper pink +- **Brand Orange** ({colors.brand-orange}): Orange accent +- **Brand Orange Deep** ({colors.brand-orange-deep}): Deeper orange-rust +- **Brand Purple** ({colors.brand-purple}): Purple accent variant +- **Brand Purple 300** ({colors.brand-purple-300}): Light purple +- **Brand Purple 800** ({colors.brand-purple-800}): Deep purple for tag text +- **Brand Teal** ({colors.brand-teal}): Teal accent +- **Brand Green** ({colors.brand-green}): Bright green +- **Brand Yellow** ({colors.brand-yellow}): Soft yellow +- **Brand Brown** ({colors.brand-brown}): Brand brown for "earthy" tints + +### Card Tints (Pastel Feature Card Backgrounds) +- **Tint Peach** ({colors.card-tint-peach}): Pale peach +- **Tint Rose** ({colors.card-tint-rose}): Pale rose-pink +- **Tint Mint** ({colors.card-tint-mint}): Pale mint-green +- **Tint Lavender** ({colors.card-tint-lavender}): Pale lavender +- **Tint Sky** ({colors.card-tint-sky}): Pale sky-blue +- **Tint Yellow** ({colors.card-tint-yellow}): Pale yellow +- **Tint Yellow Bold** ({colors.card-tint-yellow-bold}): Bold yellow for high-emphasis feature banners ("Ask your on-demand assistants") +- **Tint Cream** ({colors.card-tint-cream}): Cream tint +- **Tint Gray** ({colors.card-tint-gray}): Neutral surface + +### Surface +- **Canvas White** ({colors.canvas}): Page background and primary card surface +- **Surface** ({colors.surface}): Subtle section backgrounds, search-pill rest, featured pricing tier +- **Surface Soft** ({colors.surface-soft}): Quieter section divisions +- **Hairline** ({colors.hairline}): 1px borders and primary dividers +- **Hairline Soft** ({colors.hairline-soft}): Quieter dividers +- **Hairline Strong** ({colors.hairline-strong}): Stronger 1px border for inputs + +### Text +- **Ink Deep** ({colors.ink-deep}): Pure black for emphasis +- **Ink** ({colors.ink}): Primary headlines and body text +- **Charcoal** ({colors.charcoal}): Body emphasis (Notion's signature warm-charcoal) +- **Slate** ({colors.slate}): Secondary text +- **Steel** ({colors.steel}): Tertiary, footer links +- **Stone** ({colors.stone}): Muted labels +- **Muted** ({colors.muted}): Disabled, placeholders +- **On Dark** ({colors.on-dark}): White text on dark surfaces +- **On Dark Muted** ({colors.on-dark-muted}): Reduced-opacity white + +### Semantic +- **Success** ({colors.semantic-success}): Confirmation green +- **Warning** ({colors.semantic-warning}): Mid-priority alerts (orange) +- **Error** ({colors.semantic-error}): Validation errors (red) + +## Typography + +### Font Family +**Notion Sans** (primary): Notion's custom Inter-based variable typeface. Fallbacks: Inter, -apple-system, system-ui, 'Segoe UI', Helvetica, sans-serif. Humanist-geometric character used across every UI surface. + +### Hierarchy + +| Token | Size | Weight | Line Height | Letter Spacing | Use | +|---|---|---|---|---|---| +| `{typography.hero-display}` | 80px | 600 | 1.05 | -2px | Hero ("Meet the night shift") | +| `{typography.display-lg}` | 56px | 600 | 1.10 | -1px | Section openers | +| `{typography.heading-1}` | 48px | 600 | 1.15 | -0.5px | Page-level headlines ("Try for free") | +| `{typography.heading-2}` | 36px | 600 | 1.20 | -0.5px | Subsection headlines ("Keep work moving 24/7") | +| `{typography.heading-3}` | 28px | 600 | 1.25 | 0 | Card titles | +| `{typography.heading-4}` | 22px | 600 | 1.30 | 0 | Feature tile titles | +| `{typography.heading-5}` | 18px | 600 | 1.40 | 0 | FAQ questions | +| `{typography.subtitle}` | 18px | 400 | 1.50 | 0 | Hero subtitle | +| `{typography.body-md}` | 16px | 400 | 1.55 | 0 | Primary body text | +| `{typography.body-md-medium}` | 16px | 500 | 1.55 | 0 | Body emphasis | +| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Secondary body | +| `{typography.body-sm-medium}` | 14px | 500 | 1.50 | 0 | Active sidebar, button labels | +| `{typography.caption-bold}` | 13px | 600 | 1.40 | 0 | Badge labels | +| `{typography.button-md}` | 14px | 500 | 1.30 | 0 | Button labels | + +### Principles +- Tight hero leading (1.05) on 80px display +- Negative letter-spacing on display sizes (-2px to -0.5px) +- Generous body leading (1.55) for documentation readability +- 600 weight for headlines + 500 for buttons; 400 body + +## Layout + +### Spacing System +- **Base unit**: 4px (8px primary increment) +- **Tokens**: `{spacing.xxs}` (4px) through `{spacing.hero}` (120px) +- **Section rhythm**: Marketing pages use `{spacing.section-lg}` (96px); pricing tightens to `{spacing.section}` (64px) + +### Grid & Container +- 1280px max-width with 32px gutters +- Pricing: 4-tier card row at desktop with dense comparison table +- Homepage: centered hero with workspace mockup below buttons; alternating colorful feature card sections + +### Whitespace Philosophy +Marketing surfaces use generous breathing room between feature card bands. Workspace mockup card on hero gets full-width treatment with deep drop shadow. + +## Elevation & Depth + +| Level | Treatment | Use | +|---|---|---| +| 0 (flat) | No shadow; `{colors.hairline}` border | Default cards, table rows | +| 1 (subtle) | `rgba(15, 15, 15, 0.04) 0px 1px 2px 0px` | Hover-elevated tiles | +| 2 (card) | `rgba(15, 15, 15, 0.08) 0px 4px 12px 0px` | Feature cards | +| 3 (mockup) | `rgba(15, 15, 15, 0.20) 0px 24px 48px -8px` | Hero workspace mockup card | +| 4 (modal) | `rgba(15, 15, 15, 0.16) 0px 16px 48px -8px` | Modals, dropdowns | + +### Decorative Depth +- Hero workspace mockup card uses deep diffuse drop shadow (Level 3) — significant elevation against the navy band +- Pastel feature cards carry their own visual weight via tint backgrounds +- Sticky-note dot illustrations and mesh wires add atmospheric decoration to navy hero + +## Shapes + +### Border Radius Scale + +| Token | Value | Use | +|---|---|---| +| `{rounded.xs}` | 4px | Tag chips | +| `{rounded.sm}` | 6px | Type badges | +| `{rounded.md}` | 8px | Buttons, inputs, search-pill | +| `{rounded.lg}` | 12px | Cards, pricing tiers, agent tiles, workspace mockup | +| `{rounded.xl}` | 16px | Larger feature panels | +| `{rounded.xxl}` | 20px | Featured product showcases | +| `{rounded.xxxl}` | 24px | Larger feature cards | +| `{rounded.full}` | 9999px | Status badges, pill tabs (NOT regular buttons) | + +Notion's geometry is sober-editorial — `{rounded.md}` (8px) buttons distinguish it from pill-button-everywhere brands. + +## Components + +> Per the no-hover policy, hover states are NOT documented. + +### Buttons + +**`button-primary`** — Signature purple rectangular primary CTA, the dominant action. +- Background `{colors.primary}`, text `{colors.on-primary}`, typography `{typography.button-md}`, padding `10px 18px`, rounded `{rounded.md}`. +- Pressed state `button-primary-pressed` deepens to `{colors.primary-pressed}`. +- Disabled state uses `{colors.hairline}` background. + +**`button-dark`** — Black rectangular CTA on light backgrounds. +- Background `{colors.ink-deep}`, text `{colors.on-dark}`, typography `{typography.button-md}`, padding `10px 18px`, rounded `{rounded.md}`. + +**`button-secondary`** — Outlined rectangular for secondary actions ("Request a demo"). +- Background transparent, text `{colors.ink}`, border `1px solid {colors.hairline-strong}`, typography `{typography.button-md}`, padding `10px 18px`, rounded `{rounded.md}`. + +**`button-on-dark`** — White button on dark hero bands. +- Background `{colors.on-dark}`, text `{colors.ink}`, typography `{typography.button-md}`, padding `10px 18px`, rounded `{rounded.md}`. + +**`button-secondary-on-dark`** — Outlined button on dark. +- Background transparent, text `{colors.on-dark}`, border `1px solid {colors.on-dark-muted}`, typography `{typography.button-md}`, padding `10px 18px`, rounded `{rounded.md}`. + +**`button-ghost`** — Quieter ghost button. +- Background transparent, text `{colors.ink}`, typography `{typography.button-md}`, padding `8px 12px`, rounded `{rounded.sm}`. + +**`button-link`** — Inline blue text link (NOT primary purple). +- Background transparent, text `{colors.link-blue}`, typography `{typography.body-sm-medium}`, padding `0`. + +### Cards & Containers + +**`card-base`** — Standard content card. +- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xl}`, border `1px solid {colors.hairline}`. + +**`card-feature`** — Feature card with larger padding. +- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`, border `1px solid {colors.hairline}`. + +**`card-feature-yellow-bold`** — Bold yellow feature banner for high-emphasis content ("Ask your on-demand assistants"). +- Background `{colors.card-tint-yellow-bold}`, text `{colors.charcoal}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`. + +**`card-feature-peach`** + **`card-feature-rose`** + **`card-feature-mint`** + **`card-feature-sky`** + **`card-feature-lavender`** + **`card-feature-yellow`** + **`card-feature-cream`** — Pastel-tinted feature cards. +- Each variant uses its corresponding `card-tint-*` color as background, text `{colors.charcoal}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`. + +**`card-agent-tile`** — Agent assistant tile. +- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xl}`, border `1px solid {colors.hairline}`. + +**`card-template`** — Template thumbnail card. +- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.lg}`, border `1px solid {colors.hairline}`. + +**`card-startup-perk`** — Startup-program perk grid item. +- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xl}`, border `1px solid {colors.hairline}`. + +**`pricing-card`** — Standard pricing tier card. +- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`, border `1px solid {colors.hairline}`. + +**`pricing-card-featured`** — Featured pricing tier (Plus or Business — purple-bordered). +- Background `{colors.surface}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`, border `2px solid {colors.primary}`. + +### Inputs & Forms + +**`text-input`** — Standard text field. +- Background `{colors.canvas}`, text `{colors.ink}`, border `1px solid {colors.hairline-strong}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 44px. + +**`text-input-focused`** — Activated state. +- Border switches to `2px solid {colors.primary}` (purple). + +**`search-pill`** — Search bar. +- Background `{colors.surface}`, text `{colors.steel}`, typography `{typography.body-md}`, rounded `{rounded.md}`, height 44px, border `1px solid {colors.hairline}`. + +### Tabs + +**`pill-tab`** + **`pill-tab-active`** — Pill-style tab nav for top-level switching. +- Inactive: text `{colors.steel}`, border `1px solid {colors.hairline}`, padding `{spacing.xs} {spacing.md}`, rounded `{rounded.full}`. +- Active: background `{colors.ink-deep}`, text `{colors.on-dark}`. + +**`segmented-tab`** + **`segmented-tab-active`** — Underline-style tab navigation. +- Inactive: text `{colors.steel}`, no border. Active: text `{colors.ink}`, 2px bottom border in `{colors.ink}`. + +### Badges & Status + +**`badge-purple`** — Purple status badge (matches primary CTA). +- Background `{colors.primary}`, text `{colors.on-primary}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `4px 10px`. + +**`badge-pink`** — Pink accent badge. +- Background `{colors.brand-pink}`, text `{colors.on-primary}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `4px 10px`. + +**`badge-orange`** — Orange accent badge. +- Background `{colors.brand-orange}`, text `{colors.on-primary}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `4px 10px`. + +**`badge-tag-purple`** — Soft-purple feature tag chip. +- Background `{colors.card-tint-lavender}`, text `{colors.brand-purple-800}`, typography `{typography.caption-bold}`, rounded `{rounded.sm}`, padding `2px 8px`. + +**`badge-tag-orange`** — Soft-orange feature tag. +- Background `{colors.card-tint-peach}`, text `{colors.brand-orange-deep}`, typography `{typography.caption-bold}`, rounded `{rounded.sm}`, padding `2px 8px`. + +**`badge-tag-green`** — Soft-mint feature tag. +- Background `{colors.card-tint-mint}`, text `{colors.brand-green}`, typography `{typography.caption-bold}`, rounded `{rounded.sm}`, padding `2px 8px`. + +**`badge-popular`** — "Most Popular" tier indicator. +- Background `{colors.primary}`, text `{colors.on-primary}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `4px 10px`. + +**`promo-banner`** — Light surface promo strip ABOVE the top nav. +- Background `{colors.surface}`, text `{colors.ink}`, typography `{typography.body-sm-medium}`, padding `{spacing.sm} {spacing.md}`. ("Developers: Get a first look at our new Developer Platform on May 13.") + +### Tables + +**`comparison-table`** — Pricing feature comparison table. +- Background `{colors.canvas}`, text `{colors.ink}`, typography `{typography.body-sm}`, rounded `{rounded.md}`, border `1px solid {colors.hairline}`. + +**`comparison-row`** — Individual feature row. +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md} {spacing.lg}`, bottom border `1px solid {colors.hairline-soft}`. + +### Documentation Components + +**`workspace-mockup-card`** — Embedded Notion workspace UI mockup on hero band ("Ramp HQ" kanban board). +- Background `{colors.canvas}`, rounded `{rounded.lg}`, border `1px solid {colors.hairline}`, deep shadow `rgba(15, 15, 15, 0.20) 0px 24px 48px -8px`. Carries actual Notion product UI mock. + +**`testimonial-card`** — Customer testimonial card. +- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`, border `1px solid {colors.hairline}`. + +**`logo-wall-item`** — Customer logo wordmark cell. +- Background transparent, text `{colors.steel}`, typography `{typography.body-md-medium}`, padding `{spacing.lg}`. + +**`faq-accordion-item`** — FAQ panel. +- Background `{colors.canvas}`, rounded `{rounded.md}`, padding `{spacing.xl}`, bottom border `1px solid {colors.hairline}`. + +**`stat-row`** — Stats strip with bar chart visualization ("More productivity. Fewer tools."). +- Background `{colors.surface}`, text `{colors.ink}`, rounded `{rounded.lg}`, padding `{spacing.section-sm}`. + +**`cta-banner-light`** — Light surface CTA banner. +- Background `{colors.surface}`, text `{colors.ink}`, rounded `{rounded.lg}`, padding `{spacing.section}`. + +### Navigation + +**Top Navigation (Marketing)** — Sticky white bar. +- Background `{colors.canvas}`, height ~64px, bottom border `1px solid {colors.hairline}`. +- Left: Notion "N" logo + "Product / AI / Solutions / Resources / Enterprise / Pricing / Request a demo" links. +- Right: "Get Notion free" purple button + "Log in" link. + +### Signature Components + +**`hero-band-dark`** — Deep navy hero band with embedded workspace mockup and decorative dots/wires. +- Background `{colors.brand-navy}`, text `{colors.on-dark}`, padding `{spacing.hero}`. +- Layout: centered headline `{typography.hero-display}`, subtitle, button row (`button-primary` purple + `button-secondary-on-dark`), `workspace-mockup-card` below. +- Atmospheric decoration: scattered colorful sticky-note dots and mesh wire illustrations around the hero content (NOT a literal pattern fill — handled per-page via SVG/illustration). + +**`footer-region`** — Multi-column light footer. +- Background `{colors.canvas}`, padding `{spacing.section} {spacing.xxl}`, top border `1px solid {colors.hairline}`. +- 6-column link grid (Product / Download / Resources / Notion for / Company / Legal). + +**`footer-link`** — Individual footer link. +- Background transparent, text `{colors.steel}`, typography `{typography.body-sm}`, padding `{spacing.xxs} 0`. + +## Do's and Don'ts + +### Do +- Use `{colors.primary}` (purple) as the dominant CTA across all surfaces — it's the brand's recognizable signal +- Pair deep navy hero bands ({colors.brand-navy}) with the purple button + decorative sticky-note dots +- Use pastel feature card tints (peach, rose, mint, lavender, sky, yellow) generously +- Use `{colors.card-tint-yellow-bold}` for high-emphasis "Ask the assistant"-style banner cards +- Apply `{rounded.md}` (8px) to buttons consistently — Notion uses rectangles, not pills +- Apply `{rounded.lg}` (12px) to all card families +- Maintain Notion-Sans across every UI surface +- Use the workspace mockup card on hero bands to show actual product UI + +### Don't +- Don't use the purple for body text or large background surfaces +- Don't use pill-shaped buttons; Notion's geometry is rectangular-sober +- Don't mix link-blue ({colors.link-blue}) with primary-purple ({colors.primary}) — they have distinct roles +- Don't apply heavy shadows on flat documentation cards +- Don't replace Notion-Sans with a generic Inter + +## Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|---|---|---| +| Mobile (small) | < 480px | Single column. Hero 36px. Pricing 1-up. | +| Mobile (large) | 480 – 767px | Feature cards 2-up. Hero 48px. | +| Tablet | 768 – 1023px | 2-column feature grids. Hero 56px. | +| Desktop | 1024 – 1279px | 4-tier pricing card row. Hero 72px. | +| Wide Desktop | ≥ 1280px | Full 80px hero presentation. | + +### Touch Targets +- Buttons render at 40–44px effective height +- Form inputs render at 44px height +- Pill tabs ~32px → 44px on mobile + +### Collapsing Strategy +- **Promo banner** stays full-width; truncates at < 480px +- **Top nav** below 1024px collapses to hamburger +- **Hero band**: workspace mockup card moves below text/buttons on mobile +- **Pricing tiers**: 4-column → 2-column tablet → 1-column mobile +- **Feature cards**: 3-up desktop → 2-up tablet → 1-up mobile +- **Hero typography**: 80px → 56px → 48px → 36px +- **Footer**: 6-column desktop → 3-column tablet → accordion mobile + +### Image Behavior +- Workspace mockup card maintains aspect ratio +- Pastel illustrations inside feature cards scale proportionally +- Customer logo wall: wordmarks at consistent 60–80px height + +## Iteration Guide + +1. Focus on ONE component at a time +2. Reference component names and tokens directly +3. Run `npx @google/design.md lint DESIGN.md` after edits +4. Add new variants as separate `components:` entries +5. Default to `{typography.body-md}` for body +6. Keep `{colors.primary}` (purple) as the primary CTA — distinct from `{colors.link-blue}` for inline links +7. Use `{rounded.md}` for buttons (rectangles), `{rounded.lg}` for cards, `{rounded.full}` for pill tabs/badges only + +## Known Gaps + +- Specific dark-mode token values not surfaced beyond hero bands +- Animation/transition timings not extracted; recommend 150–200ms ease +- Form validation success state not explicitly captured +- Pastel-tint mapping (which feature uses which tint) is observation-based — the actual brand library may have more entries diff --git a/backend/apps/chat/api/chat.py b/backend/apps/chat/api/chat.py index 0e6ff7ee7..f06a8e13e 100644 --- a/backend/apps/chat/api/chat.py +++ b/backend/apps/chat/api/chat.py @@ -5,7 +5,7 @@ import orjson import pandas as pd -from fastapi import APIRouter, HTTPException, Path +from fastapi import APIRouter, HTTPException, Path, Query from fastapi.responses import StreamingResponse from sqlalchemy import and_, select from starlette.responses import JSONResponse @@ -13,7 +13,8 @@ from apps.chat.curd.chat import delete_chat_with_user, get_chart_data_with_user, get_chat_predict_data_with_user, \ list_chats, get_chat_with_records, create_chat, rename_chat, \ delete_chat, get_chat_chart_data, get_chat_predict_data, get_chat_with_records_with_data, get_chat_record_by_id, \ - format_json_data, format_json_list_data, get_chart_config, list_recent_questions, get_chat as get_chat_exec, \ + format_json_data, format_json_list_data, get_chart_config, list_recent_questions, list_popular_questions, \ + get_chat as get_chat_exec, \ rename_chat_with_user, get_chat_log_history, get_chart_data_with_user_live from apps.chat.models.chat_model import CreateChat, ChatRecord, RenameChat, ChatQuestion, AxisObj, QuickCommand, \ ChatInfo, Chat, ChatFinishStep @@ -34,6 +35,17 @@ async def chats(session: SessionDep, current_user: CurrentUser): return list_chats(session, current_user) +@router.get("/popular_questions", summary=f"{PLACEHOLDER_PREFIX}popular_questions_workspace") +async def popular_questions( + session: SessionDep, current_user: CurrentUser, limit: int = Query(8, ge=1, le=50) +): + """工作空间内提问频次统计(排除首条占位记录)。""" + def inner(): + return list_popular_questions(session=session, current_user=current_user, limit=limit) + + return await asyncio.to_thread(inner) + + @router.get("/{chart_id}", response_model=ChatInfo, summary=f"{PLACEHOLDER_PREFIX}get_chat") async def get_chat(session: SessionDep, current_user: CurrentUser, chart_id: int, current_assistant: CurrentAssistant, trans: Trans): diff --git a/backend/apps/chat/curd/chat.py b/backend/apps/chat/curd/chat.py index 1f8befdf5..810c1bb6e 100644 --- a/backend/apps/chat/curd/chat.py +++ b/backend/apps/chat/curd/chat.py @@ -20,6 +20,8 @@ from common.utils.data_format import DataFormat from common.utils.utils import extract_nested_json, SQLBotLogUtil +from apps.chat.utils.popular_questions_cluster import cluster_questions_for_datasource + def get_chat_record_by_id(session: SessionDep, record_id: int): record: ChatRecord | None = None @@ -66,6 +68,58 @@ def list_recent_questions(session: SessionDep, current_user: CurrentUser, dataso return [record[0] for record in chat_records] if chat_records else [] +def list_popular_questions(session: SessionDep, current_user: CurrentUser, limit: int = 8) -> List[Dict[str, Any]]: + """按数据源 + 语义合并统计热门问题(同一数据源内相近问句合并,非纯字面 group_by)。""" + oid = current_user.oid if current_user.oid is not None else 1 + limit = min(max(limit, 1), 50) + cnt = func.count(ChatRecord.id).label('cnt') + rows = ( + session.query(Chat.datasource, ChatRecord.question, cnt) + .join(Chat, ChatRecord.chat_id == Chat.id) + .filter( + Chat.oid == oid, + Chat.create_by == current_user.id, + Chat.datasource.isnot(None), + ChatRecord.question.isnot(None), + ChatRecord.question != '', + ChatRecord.first_chat.isnot(True), + ) + .group_by(Chat.datasource, ChatRecord.question) + .order_by(desc(cnt)) + .limit(400) + .all() + ) + by_ds: Dict[Any, List[tuple]] = {} + for ds_id, question, c in rows: + by_ds.setdefault(ds_id, []).append((question, int(c))) + + ds_ids = [k for k in by_ds.keys() if k is not None] + id_to_name: Dict[Any, str] = {} + if ds_ids: + ds_rows = session.query(CoreDatasource.id, CoreDatasource.name).filter( + CoreDatasource.id.in_(ds_ids), + CoreDatasource.oid == oid, + ).all() + id_to_name = {r[0]: r[1] for r in ds_rows} + + flat: List[Dict[str, Any]] = [] + for ds_id, weighted in by_ds.items(): + if ds_id is None: + continue + for rep_q, total in cluster_questions_for_datasource(weighted): + flat.append( + { + 'datasource_id': int(ds_id), + 'datasource_name': id_to_name.get(ds_id) or '', + 'question': rep_q, + 'count': total, + } + ) + + flat.sort(key=lambda x: (-x['count'], x.get('datasource_name') or '')) + return flat[:limit] + + def rename_chat_with_user(session: SessionDep, current_user: CurrentUser, rename_object: RenameChat) -> str: chat = session.get(Chat, rename_object.id) if not chat: @@ -211,7 +265,7 @@ def format_json_list_data(origin_data: list[dict]): if len(decimal_str) > 15: value = str(value) _row[key] = value - data.append(_row) + data.append(DataFormat.normalize_qualified_sql_column_keys(_row)) return data @@ -253,6 +307,7 @@ def get_chart_data_ds(session: SessionDep,ds_id,sql): else: result = exec_sql(ds=datasource,sql=sql, origin_column=False) _data = DataFormat.convert_large_numbers_in_object_array(result.get('data')) + _data = DataFormat.normalize_qualified_sql_column_keys_in_object_array(_data) json_result['data'] = _data return json_result except Exception as e: diff --git a/backend/apps/chat/models/chat_model.py b/backend/apps/chat/models/chat_model.py index 66ef2060d..b0a6c2c73 100644 --- a/backend/apps/chat/models/chat_model.py +++ b/backend/apps/chat/models/chat_model.py @@ -229,6 +229,7 @@ class AiModelQuestion(BaseModel): custom_prompt: str = "" error_msg: str = "" regenerate_record_id: Optional[int] = None + sample_data: str = "" def sql_sys_question(self, db_type: Union[str, DB], enable_query_limit: bool = True): templates: dict[str, str] = {} @@ -256,7 +257,7 @@ def sql_sys_question(self, db_type: Union[str, DB], enable_query_limit: bool = T example_answer_1=_example_answer_1, example_answer_2=_example_answer_2, example_answer_3=_example_answer_3) - templates['schema'] = _base_template['generate_basic_info'].format(engine=self.engine, schema=self.db_schema) + templates['schema'] = _base_template['generate_basic_info'].format(engine=self.engine, schema=self.db_schema, sample_data=self.sample_data) if self.terminologies: templates['terminologies'] = _base_template['generate_terminologies_info'].format( diff --git a/backend/apps/chat/task/llm.py b/backend/apps/chat/task/llm.py index 006b8bb9a..cf469b371 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -36,7 +36,7 @@ from apps.chat.models.chat_model import ChatQuestion, ChatRecord, Chat, RenameChat, ChatLog, OperationEnum, \ ChatFinishStep, AxisObj, SystemPromptMessage, HumanPromptMessage, AIPromptMessage from apps.data_training.curd.data_training import get_training_template -from apps.datasource.crud.datasource import get_table_schema +from apps.datasource.crud.datasource import get_table_schema, get_tables_sample_data from apps.datasource.crud.permission import get_row_permission_filters, is_normal_user from apps.datasource.embedding.ds_embedding import get_ds_embedding from apps.datasource.models.datasource import CoreDatasource @@ -384,6 +384,13 @@ def choose_table_schema(self, _session: Session): ds=self.ds, question=self.chat_question.question) + # Get sample data for all tables + if not self.out_ds_instance: + self.chat_question.sample_data = get_tables_sample_data( + session=_session, + current_user=self.current_user, + ds=self.ds) + self.current_logs[OperationEnum.CHOOSE_TABLE] = end_log(session=_session, log=self.current_logs[OperationEnum.CHOOSE_TABLE], full_message=self.chat_question.db_schema) @@ -505,6 +512,13 @@ def generate_recommend_questions_task(self, _session: Session): question=self.chat_question.question, embedding=False) + # Get sample data for all tables + if not self.out_ds_instance: + self.chat_question.sample_data = get_tables_sample_data( + session=_session, + current_user=self.current_user, + ds=self.ds) + guess_msg: List[Union[BaseMessage, dict[str, Any]]] = [] guess_msg.append(SystemPromptMessage(content=self.chat_question.guess_sys_question(self.articles_number))) @@ -1304,6 +1318,7 @@ def run_task(self, in_chat: bool = True, stream: bool = True, 'count': len(result.get('data'))}) _data = DataFormat.convert_large_numbers_in_object_array(result.get('data')) + _data = DataFormat.normalize_qualified_sql_column_keys_in_object_array(_data) result["data"] = _data self.save_sql_data(session=_session, data_obj=result) diff --git a/backend/apps/chat/utils/popular_questions_cluster.py b/backend/apps/chat/utils/popular_questions_cluster.py new file mode 100644 index 000000000..061e041d5 --- /dev/null +++ b/backend/apps/chat/utils/popular_questions_cluster.py @@ -0,0 +1,129 @@ +""" +热门问题:按数据源聚合,并在同一数据源内做语义相近合并(非纯字面 group_by)。 + +1. 意图桶:库表/数据概览类中文问法合并为同一主题(见 META_OVERVIEW_PATTERN)。 +2. 向量聚类:对其余问句用本地中文 embedding 做余弦相似度合并(可选,失败则回退)。 +3. 回退:归一化 + difflib 合并相近字面。 +""" + +from __future__ import annotations + +import re +from difflib import SequenceMatcher +from typing import Any, Dict, List, Tuple + +import numpy as np + +# 表/数据量/有哪些数据 等「元信息」类问题归为一类(用户示例) +META_OVERVIEW_PATTERN = re.compile( + r"(几张表|哪些表|多少张表|有多少表|表.*数据量|数据量.*表|分别.*数据量|数据量.*多大|" + r"哪些数据|有什么数据|有哪些数据|什么数据|库表|schema|多少条数据|统计.*表|表的.*数量)", + re.IGNORECASE, +) + + +def normalize_question(s: str) -> str: + if not s: + return "" + t = s.strip() + t = re.sub(r"[\s\u3000]+", "", t) + t = re.sub(r"[。..!?!?;;,、]+$", "", t) + return t + + +def _split_meta_overview( + weighted: List[Tuple[str, int]], +) -> Tuple[List[Tuple[str, int]], List[Tuple[str, int]]]: + meta: List[Tuple[str, int]] = [] + rest: List[Tuple[str, int]] = [] + for q, c in weighted: + if META_OVERVIEW_PATTERN.search(q): + meta.append((q, c)) + else: + rest.append((q, c)) + out: List[Tuple[str, int]] = [] + if meta: + rep = max(meta, key=lambda x: x[1])[0] + total = sum(c for _, c in meta) + out.append((rep, total)) + return out, rest + + +def _merge_difflib(weighted: List[Tuple[str, int]], threshold: float = 0.78) -> List[Tuple[str, int]]: + if not weighted: + return [] + items = sorted(weighted, key=lambda x: -x[1]) + clusters: List[Dict[str, Any]] = [] + for q, c in items: + nq = normalize_question(q) + best_i = -1 + best_r = 0.0 + for i, cl in enumerate(clusters): + r = SequenceMatcher(None, nq, cl["norm"]).ratio() + if r >= threshold and r > best_r: + best_r = r + best_i = i + if best_i >= 0: + clusters[best_i]["count"] += c + if c > clusters[best_i].get("max_w", 0): + clusters[best_i]["rep"] = q + clusters[best_i]["max_w"] = c + else: + clusters.append({"rep": q, "count": c, "norm": nq, "max_w": c}) + return [(c["rep"], int(c["count"])) for c in clusters] + + +def _merge_embedding(weighted: List[Tuple[str, int]], threshold: float = 0.76) -> List[Tuple[str, int]]: + if len(weighted) <= 1: + return weighted + try: + from apps.ai_model.embedding import EmbeddingModelCache + + texts = [w[0] for w in weighted] + model = EmbeddingModelCache.get_model() + embs = model.embed_documents(texts) + arr = np.array(embs, dtype=np.float32) + norms = np.linalg.norm(arr, axis=1, keepdims=True) + 1e-9 + arr = arr / norms + n = len(weighted) + parent = list(range(n)) + + def find(a: int) -> int: + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a + + def union(a: int, b: int) -> None: + ra, rb = find(a), find(b) + if ra != rb: + parent[rb] = ra + + sim = arr @ arr.T + for i in range(n): + for j in range(i + 1, n): + if float(sim[i, j]) >= threshold: + union(i, j) + groups: Dict[int, List[int]] = {} + for i in range(n): + r = find(i) + groups.setdefault(r, []).append(i) + out: List[Tuple[str, int]] = [] + for idxs in groups.values(): + total = sum(weighted[i][1] for i in idxs) + rep_q = max((weighted[i] for i in idxs), key=lambda x: x[1])[0] + out.append((rep_q, int(total))) + return out + except Exception: + return _merge_difflib(weighted, threshold=0.78) + + +def cluster_questions_for_datasource(weighted: List[Tuple[str, int]]) -> List[Tuple[str, int]]: + """同一数据源下多组 (原文, 次数) -> 合并后 (代表问句, 总次数)。""" + if not weighted: + return [] + meta_merged, rest = _split_meta_overview(weighted) + if not rest: + return meta_merged + embedded_or_fb = _merge_embedding(rest) + return meta_merged + embedded_or_fb diff --git a/backend/apps/datasource/api/datasource.py b/backend/apps/datasource/api/datasource.py index d4e24b7a0..a5a53fc9b 100644 --- a/backend/apps/datasource/api/datasource.py +++ b/backend/apps/datasource/api/datasource.py @@ -231,6 +231,7 @@ def inner(): try: return preview(session, current_user, id, data) except Exception as e: + SQLBotLogUtil.error(f"Preview failed: {e}, try another way") ds = session.query(CoreDatasource).filter(CoreDatasource.id == id).first() # check ds status status = check_status(session, trans, ds, True) diff --git a/backend/apps/datasource/crud/datasource.py b/backend/apps/datasource/crud/datasource.py index 5a4cc6223..ae547a712 100644 --- a/backend/apps/datasource/crud/datasource.py +++ b/backend/apps/datasource/crud/datasource.py @@ -17,7 +17,7 @@ from common.core.config import settings from common.core.deps import SessionDep, CurrentUser, Trans from common.utils.embedding_threads import run_save_table_embeddings, run_save_ds_embeddings -from common.utils.utils import SQLBotLogUtil, deepcopy_ignore_extra +from common.utils.utils import SQLBotLogUtil, deepcopy_ignore_extra, equals_ignore_case from common.core.sqlbot_cache import cache, clear_cache from .table import get_tables_by_ds_id from ..crud.field import delete_field_by_ds_id, update_field @@ -329,8 +329,8 @@ def preview(session: SessionDep, current_user: CurrentUser, id: int, data: Table conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() sql: str = "" - if ds.type == "mysql" or ds.type == "doris" or ds.type == "starrocks": - sql = f"""SELECT `{"`, `".join(fields)}` FROM `{data.table.table_name}` + if ds.type == "mysql" or ds.type == "doris" or ds.type == "starrocks" or ds.type == "hive": + sql = f"""SELECT `{"`, `".join(fields)}` FROM `{conf.database}`.`{data.table.table_name}` {where} LIMIT 100""" elif ds.type == "sqlServer": @@ -357,12 +357,16 @@ def preview(session: SessionDep, current_user: CurrentUser, id: int, data: Table {where} LIMIT 100""" elif ds.type == "dm": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" - {where} + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" + {where} LIMIT 100""" elif ds.type == "es": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{data.table.table_name}" - {where} + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{data.table.table_name}" + {where} + LIMIT 100""" + elif ds.type == "sqlite": + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{data.table.table_name}" + {where} LIMIT 100""" return exec_sql(ds, sql, True) @@ -430,6 +434,79 @@ def get_table_obj_by_ds(session: SessionDep, current_user: CurrentUser, ds: Core return _list +def get_table_sample_data(ds: CoreDatasource, table_name: str, fields: list) -> str: + """Get 3 sample rows from a table in JSON format to help AI understand the data""" + if not fields: + return "" + + db = DB.get_db(ds.type) + # Get prefix/suffix for identifier quoting + prefix = db.prefix if hasattr(db, 'prefix') else '"' + suffix = db.suffix if hasattr(db, 'suffix') else '"' + + # Build field list with proper quoting + field_names = [] + for field in fields[:10]: # Limit to first 10 fields to avoid too wide results + field_name = f"{prefix}{field.field_name}{suffix}" + field_names.append(field_name) + + # Build LIMIT query based on database type + if equals_ignore_case(ds.type, "sqlServer"): + query = f"SELECT TOP 3 {','.join(field_names)} FROM {prefix}{table_name}{suffix}" + elif equals_ignore_case(ds.type, "ck"): + query = f"SELECT {','.join(field_names)} FROM {table_name} LIMIT 3" + elif equals_ignore_case(ds.type, "hive"): + query = f"SELECT {','.join(field_names)} FROM {table_name} LIMIT 3" + elif equals_ignore_case(ds.type, "oracle"): + query = f"SELECT {','.join(field_names)} FROM \"{table_name}\" WHERE ROWNUM <= 3" + elif equals_ignore_case(ds.type, "dm"): + query = f"SELECT {','.join(field_names)} FROM \"{table_name}\" WHERE ROWNUM <= 3" + else: + query = f"SELECT {','.join(field_names)} FROM {prefix}{table_name}{suffix} LIMIT 3" + + try: + result = exec_sql(ds=ds, sql=query, origin_column=True) + if result and result.get('data') and len(result['data']) > 0: + import json + # Truncate long string values for readability + json_rows = [] + for row in result['data'][:3]: + truncated_row = {} + for key, value in row.items(): + if value is None: + truncated_row[key] = None + elif isinstance(value, str): + # Truncate long strings + if len(value) > 100: + value = value[:100] + '...' + truncated_row[key] = value.replace('\n', ' ').replace('\r', ' ') + else: + truncated_row[key] = value + json_rows.append(truncated_row) + return json.dumps(json_rows, ensure_ascii=False, indent=2) + except Exception: + pass + return "" + + +def get_tables_sample_data(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, + table_list: list[str] = None) -> str: + """Get sample data (3 rows) for all tables to help AI understand the data""" + table_objs = get_table_obj_by_ds(session=session, current_user=current_user, ds=ds) + if len(table_objs) == 0: + return "" + + sample_data_parts = [] + for obj in table_objs: + if table_list is not None and obj.table.table_name not in table_list: + continue + if obj.fields: + sample = get_table_sample_data(ds, obj.table.table_name, obj.fields) + if sample: + sample_data_parts.append(f"# Table: {obj.table.table_name}\n{sample}") + return "\n".join(sample_data_parts) + + def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, question: str, embedding: bool = True, table_list: list[str] = None) -> str: schema_str = "" @@ -446,7 +523,8 @@ def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDat continue schema_table = '' - schema_table += f"# Table: {db_name}.{obj.table.table_name}" if ds.type != "mysql" and ds.type != "es" else f"# Table: {obj.table.table_name}" + no_schema_types = ["mysql", "es", "sqlite", "hive", "doris", "starrocks"] + schema_table += f"# Table: {db_name}.{obj.table.table_name}" if ds.type not in no_schema_types and db_name else f"# Table: {obj.table.table_name}" table_comment = '' if obj.table.custom_comment: table_comment = obj.table.custom_comment.strip() diff --git a/backend/apps/datasource/models/datasource.py b/backend/apps/datasource/models/datasource.py index 6a23e0b7f..3971318cf 100644 --- a/backend/apps/datasource/models/datasource.py +++ b/backend/apps/datasource/models/datasource.py @@ -143,7 +143,7 @@ def to_dict(self): class TableSchema: - def __init__(self, attr1, attr2): + def __init__(self, attr1, attr2=None): self.tableName = attr1 self.tableComment = attr2 if attr2 is None or isinstance(attr2, str) else attr2.decode("utf-8") diff --git a/backend/apps/db/constant.py b/backend/apps/db/constant.py index 1509fcf47..6ee33f02f 100644 --- a/backend/apps/db/constant.py +++ b/backend/apps/db/constant.py @@ -28,6 +28,8 @@ class DB(Enum): oracle = ('oracle', 'Oracle', '"', '"', ConnectType.sqlalchemy, 'Oracle', []) pg = ('pg', 'PostgreSQL', '"', '"', ConnectType.sqlalchemy, 'PostgreSQL', []) starrocks = ('starrocks', 'StarRocks', '`', '`', ConnectType.py_driver, 'StarRocks', []) + sqlite = ('sqlite', 'SQLite', '"', '"', ConnectType.sqlalchemy, 'SQLite', []) + hive = ('hive', 'Apache Hive', '`', '`', ConnectType.py_driver, 'Hive', []) def __init__(self, type, db_name, prefix, suffix, connect_type: ConnectType, template_name: str, illegalParams: List[str]): diff --git a/backend/apps/db/db.py b/backend/apps/db/db.py index bbf9e97e1..99e5911b7 100644 --- a/backend/apps/db/db.py +++ b/backend/apps/db/db.py @@ -2,6 +2,7 @@ import json import os import platform +import re import urllib.parse from datetime import datetime, date, time, timedelta from decimal import Decimal @@ -35,6 +36,8 @@ import sqlglot from sqlglot import expressions as exp from sqlalchemy.pool import NullPool +from pyhive import hive + try: if os.path.exists(settings.ORACLE_CLIENT_PATH): @@ -88,6 +91,8 @@ def get_uri_from_config(type: str, conf: DatasourceConf) -> str: db_url = f"clickhouse+http://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}?{conf.extraJdbc}" else: db_url = f"clickhouse+http://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}" + elif equals_ignore_case(type, "sqlite"): + db_url = f"sqlite:///{conf.filename}" else: raise 'The datasource type not support.' return db_url @@ -157,6 +162,8 @@ def get_engine(ds: CoreDatasource, timeout: int = 0) -> Engine: elif equals_ignore_case(ds.type, 'mysql'): # mysql ssl_mode = {"require": True} if conf.ssl else None engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout, "ssl": ssl_mode}, poolclass=NullPool) + elif equals_ignore_case(ds.type, 'sqlite'): + engine = create_engine(get_uri(ds), connect_args={"check_same_thread": False}, poolclass=NullPool) else: # ck engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout}, poolclass=NullPool) return engine @@ -207,9 +214,10 @@ def check_connection(trans: Optional[Trans], ds: CoreDatasource | AssistantOutDs raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') return False elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=10, - read_timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=10, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: try: cursor.execute('select 1') SQLBotLogUtil.info("success") @@ -247,6 +255,23 @@ def check_connection(trans: Optional[Trans], ds: CoreDatasource | AssistantOutDs if is_raise: raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') return False + elif equals_ignore_case(ds.type, 'hive'): + try: + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute('select 1') + cursor.fetchall() + cursor.close() + conn.close() + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + return False + elif equals_ignore_case(ds.type, 'es'): es_conn = get_es_connect(conf) if es_conn.ping(): @@ -289,6 +314,8 @@ def get_version(ds: CoreDatasource | AssistantOutDsSchema): # conf.timeout = 10 db = DB.get_db(ds.type) sql = get_version_sql(ds, conf) + if equals_ignore_case(ds.type, 'sqlite'): + return '' try: if db.connect_type == ConnectType.sqlalchemy: with get_session(ds) as session: @@ -304,13 +331,14 @@ def get_version(ds: CoreDatasource | AssistantOutDsSchema): res = cursor.fetchall() version = res[0][0] elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=10, - read_timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=10, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: cursor.execute(sql) res = cursor.fetchall() version = res[0][0] - elif equals_ignore_case(ds.type, 'redshift', 'es'): + elif equals_ignore_case(ds.type, 'redshift', 'es', 'hive'): version = '' except Exception as e: print(e) @@ -333,6 +361,8 @@ def get_schema(ds: CoreDatasource): elif equals_ignore_case(ds.type, "oracle"): sql = """select * from all_users""" + elif equals_ignore_case(ds.type, "sqlite"): + return ['main'] with session.execute(text(sql)) as result: res = result.fetchall() res_list = [item[0] for item in res] @@ -367,6 +397,30 @@ def get_schema(ds: CoreDatasource): res = cursor.fetchall() res_list = [item[0] for item in res] return res_list + elif equals_ignore_case(ds.type, 'hive'): + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute('SHOW DATABASES') + res = cursor.fetchall() + res_list = [item[0] for item in res] + cursor.close() + conn.close() + return res_list + elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, + port=conf.port, db=conf.database, connect_timeout=10, + read_timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + cursor.execute('SHOW DATABASES') + res = cursor.fetchall() + res_list = [item[0] for item in res] + return res_list + elif equals_ignore_case(ds.type, 'ck'): + with get_session(ds) as session: + with session.execute(text('SHOW DATABASES')) as result: + res = result.fetchall() + res_list = [item[0] for item in res] + return res_list def get_tables(ds: CoreDatasource): @@ -374,7 +428,16 @@ def get_tables(ds: CoreDatasource): "excel") else get_engine_config() db = DB.get_db(ds.type) sql, sql_param = get_table_sql(ds, conf, get_version(ds)) - if db.connect_type == ConnectType.sqlalchemy: + if equals_ignore_case(ds.type, "sqlite"): + engine = get_engine(ds) + with engine.raw_connection() as conn: + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + cursor.close() + res_list = [TableSchema(*item) for item in res] + return res_list + elif db.connect_type == ConnectType.sqlalchemy: with get_session(ds) as session: with session.execute(text(sql), {"param": sql_param}) as result: res = result.fetchall() @@ -390,9 +453,10 @@ def get_tables(ds: CoreDatasource): res_list = [TableSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=conf.timeout, - read_timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=conf.timeout, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: cursor.execute(sql, (sql_param,)) res = cursor.fetchall() res_list = [TableSchema(*item) for item in res] @@ -418,6 +482,16 @@ def get_tables(ds: CoreDatasource): res = get_es_index(conf) res_list = [TableSchema(*item) for item in res] return res_list + elif equals_ignore_case(ds.type, 'hive'): + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + res_list = [TableSchema(*item) for item in res] + cursor.close() + conn.close() + return res_list def get_fields(ds: CoreDatasource, table_name: str = None): @@ -425,7 +499,16 @@ def get_fields(ds: CoreDatasource, table_name: str = None): "excel") else get_engine_config() db = DB.get_db(ds.type) sql, p1, p2 = get_field_sql(ds, conf, table_name) - if db.connect_type == ConnectType.sqlalchemy: + if equals_ignore_case(ds.type, "sqlite"): + engine = get_engine(ds) + with engine.raw_connection() as conn: + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + cursor.close() + res_list = [ColumnSchema(item[1], item[2], '') for item in res] + return res_list + elif db.connect_type == ConnectType.sqlalchemy: with get_session(ds) as session: with session.execute(text(sql), {"param1": p1, "param2": p2}) as result: res = result.fetchall() @@ -441,9 +524,10 @@ def get_fields(ds: CoreDatasource, table_name: str = None): res_list = [ColumnSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=conf.timeout, - read_timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=conf.timeout, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: cursor.execute(sql, (p1, p2)) res = cursor.fetchall() res_list = [ColumnSchema(*item) for item in res] @@ -469,6 +553,16 @@ def get_fields(ds: CoreDatasource, table_name: str = None): res = get_es_fields(conf, table_name) res_list = [ColumnSchema(*item) for item in res] return res_list + elif equals_ignore_case(ds.type, 'hive'): + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + res_list = [ColumnSchema(*item) for item in res] + cursor.close() + conn.close() + return res_list def convert_value(value, datetime_format='space'): @@ -587,9 +681,10 @@ def exec_sql(ds: CoreDatasource | AssistantOutDsSchema, sql: str, origin_column= except Exception as ex: raise ParseSQLResultError(str(ex)) elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=conf.timeout, - read_timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=conf.timeout, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: try: cursor.execute(sql) res = cursor.fetchall() @@ -655,15 +750,54 @@ def exec_sql(ds: CoreDatasource | AssistantOutDsSchema, sql: str, origin_column= "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} except Exception as ex: raise Exception(str(ex)) + elif equals_ignore_case(ds.type, 'hive'): + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + try: + # Hive uses backticks for identifiers; normalize quoted identifiers as a compatibility fallback. + hive_sql = re.sub(r'"([A-Za-z_][A-Za-z0-9_]*)"', r'`\1`', sql) + cursor.execute(hive_sql) + res = cursor.fetchall() + columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for + field in + cursor.description] + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, + "sql": bytes.decode(base64.b64encode(bytes(hive_sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + finally: + cursor.close() + conn.close() def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema): try: + normalized_sql = sql.strip().lstrip("(").strip() + first_keyword = normalized_sql.split(None, 1)[0].upper() if normalized_sql else "" + allowed_read_commands = {"SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN"} + denied_write_commands = { + "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", + "TRUNCATE", "MERGE", "COPY", "REPLACE", "GRANT", "REVOKE", + "USE", "SET", "CALL" + } + + if not first_keyword: + raise ValueError("Parse SQL Error") + if first_keyword in denied_write_commands: + return False + dialect = None if equals_ignore_case(ds.type, 'mysql', 'doris', 'starrocks'): dialect = 'mysql' elif equals_ignore_case(ds.type, 'sqlServer'): dialect = 'tsql' + elif equals_ignore_case(ds.type, 'hive'): + dialect = 'hive' statements = sqlglot.parse(sql, dialect=dialect) @@ -673,7 +807,7 @@ def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema): write_types = ( exp.Insert, exp.Update, exp.Delete, exp.Create, exp.Drop, exp.Alter, - exp.Merge, exp.Command, exp.Copy + exp.Merge, exp.Copy ) for stmt in statements: @@ -682,7 +816,7 @@ def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema): if isinstance(stmt, write_types): return False - return True + return first_keyword in allowed_read_commands except Exception as e: raise ValueError(f"Parse SQL Error: {e}") diff --git a/backend/apps/db/db_sql.py b/backend/apps/db/db_sql.py index 566fbeb03..d4c3c74f8 100644 --- a/backend/apps/db/db_sql.py +++ b/backend/apps/db/db_sql.py @@ -31,6 +31,8 @@ def get_version_sql(ds: CoreDatasource, conf: DatasourceConf): """ elif equals_ignore_case(ds.type, "redshift"): return '' + elif equals_ignore_case(ds.type, "sqlite"): + return '' def get_table_sql(ds: CoreDatasource, conf: DatasourceConf, db_version: str = ''): @@ -162,6 +164,17 @@ def get_table_sql(ds: CoreDatasource, conf: DatasourceConf, db_version: str = '' """, conf.dbSchema elif equals_ignore_case(ds.type, "es"): return "", None + elif equals_ignore_case(ds.type, "sqlite"): + return """ + SELECT name AS TABLE_NAME, '' + FROM sqlite_master + WHERE type='table' + ORDER BY name + """, None + elif equals_ignore_case(ds.type, "hive"): + return """ + SHOW TABLES + """, None def get_field_sql(ds: CoreDatasource, conf: DatasourceConf, table_name: str = None): @@ -312,3 +325,9 @@ def get_field_sql(ds: CoreDatasource, conf: DatasourceConf, table_name: str = No return sql1 + sql2, conf.dbSchema, table_name elif equals_ignore_case(ds.type, "es"): return "", None, None + elif equals_ignore_case(ds.type, "sqlite"): + sql1 = f"PRAGMA table_info({table_name})" + return sql1, None, None + elif equals_ignore_case(ds.type, "hive"): + sql1 = f"DESCRIBE {table_name}" + return sql1, None, None diff --git a/backend/common/utils/data_format.py b/backend/common/utils/data_format.py index 1991fb3e4..bfa9e88b5 100644 --- a/backend/common/utils/data_format.py +++ b/backend/common/utils/data_format.py @@ -17,6 +17,34 @@ def safe_convert_to_string(df): return df_copy + @staticmethod + def normalize_qualified_sql_column_keys(row: dict) -> dict: + """Add unqualified keys for names like ``alias.column`` (Hive/MySQL return shape). + + Chart bindings use the bare column name (``table_name``) while drivers may return + ``_u2.table_name``. Only adds ``short`` when absent to avoid clobbering real duplicates. + """ + if not row: + return row + out = dict(row) + for k, v in row.items(): + ks = str(k) + if "." not in ks: + continue + short = ks.rsplit(".", 1)[-1] + if short not in out: + out[short] = v + return out + + @staticmethod + def normalize_qualified_sql_column_keys_in_object_array(obj_array: list) -> list: + if not obj_array: + return obj_array + return [ + DataFormat.normalize_qualified_sql_column_keys(obj) if isinstance(obj, dict) else obj + for obj in obj_array + ] + @staticmethod def convert_large_numbers_in_object_array(obj_array, int_threshold=1e15, float_threshold=1e10): """处理对象数组,将每个对象中的大数字转换为字符串""" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f112f7eca..727efde3b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -53,7 +53,9 @@ dependencies = [ "elasticsearch[requests] (>=7.10,<8.0)", "ldap3>=2.9.1", "sqlglot>=28.6.0", - "numpy==2.3.5" + "numpy==2.3.5", + "pyhive[hive]>=0.7.0", + "thrift-sasl" ] [project.optional-dependencies] diff --git a/backend/templates/sql_examples/Hive.yaml b/backend/templates/sql_examples/Hive.yaml new file mode 100644 index 000000000..813f6ab50 --- /dev/null +++ b/backend/templates/sql_examples/Hive.yaml @@ -0,0 +1,87 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加反引号(`)。 + + 1. 点号(.)不能包含在引号内,必须写成 `database`.`table` + 2. 即使标识符不含特殊字符或非关键字,也需强制加反引号 + 3. 在多表关联(JOIN)/ 多子查询的 SQL 中,只要多个表 / 子查询存在 同名字段,所有引用该字段的位置,必须显式指定 表别名 + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 不能用 + 拼接字符串,字符串必须使用单引号 + 分组非常严格:SELECT 里的字段必须出现在 GROUP BY 里,或者是聚合函数 + 函数字段必须加别名 + 百分比字段保留两位小数并以%结尾 + WHERE 条件中不能使用 >、<、>=、<= 等比较运算符,必须使用 = + HIVE 中没有 NOT IN 操作符,必须使用 LEFT JOIN 或 EXISTS 替代 + 判空使用 NVL()函数 + 避免与数据库关键字冲突 + + basic_example: | + + + 📌 以下示例严格遵循中的 Hive 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 反引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 ods.orders 表的前100条订单(含中文字段和百分比) + + SELECT * FROM ods.orders LIMIT 100 -- 错误:未加引号、使用星号 + SELECT `订单ID`, `金额` FROM `ods`.`orders` `t1` LIMIT 100 -- 错误:缺少英文别名 + SELECT COUNT(`订单ID`) FROM `ods`.`orders` `t1` -- 错误:函数未加别名 + + + SELECT + `t1`.`订单ID` AS `order_id`, + `t1`.`金额` AS `amount`, + COUNT(`t1`.`订单ID`) AS `total_orders`, + CONCAT(CAST(ROUND(`t1`.`折扣率` * 100, 2) AS STRING), '%') AS `discount_percent` + FROM `ods`.`orders` `t1` + LIMIT 100 + + + + + 统计 dim.users(含关键字字段user)的活跃占比 + + SELECT user, status FROM dim.users -- 错误:未处理关键字和引号 + SELECT `user`, ROUND(active_ratio) FROM `dim`.`users` -- 错误:百分比格式错误 + + + SELECT + `u`.`user` AS `username`, + CONCAT(CAST(ROUND(`u`.`active_ratio` * 100, 2) AS STRING), '%') AS `active_percent` + FROM `dim`.`users` `u` + WHERE `u`.`status` = 1 + + + + + example_engine: Apache Hive 2.X + example_answer_1: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year`","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year` LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/Oracle.yaml b/backend/templates/sql_examples/Oracle.yaml index 26e75297b..e32213128 100644 --- a/backend/templates/sql_examples/Oracle.yaml +++ b/backend/templates/sql_examples/Oracle.yaml @@ -8,7 +8,7 @@ template: 5. 应用其他规则(引号、别名、格式化等) 6. 最终验证:GROUP BY查询的ROWNUM位置是否正确? 7. 强制检查:验证SQL语法是否符合规范 - 8. 确定图表类型(根据规则选择table/column/bar/line/pie) + 8. 确定图表类型(根据规则选择table/column/bar/line/pie/scatter) 9. 确定对话标题 10. 生成JSON结果 11. 强制检查:JSON格式是否正确 diff --git a/backend/templates/sql_examples/SQLite.yaml b/backend/templates/sql_examples/SQLite.yaml new file mode 100644 index 000000000..bfcaacc94 --- /dev/null +++ b/backend/templates/sql_examples/SQLite.yaml @@ -0,0 +1,81 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加双引号(")。 + + 1. 点号(.)不能包含在引号内,必须写成 "table" + 2. 即使标识符不含特殊字符或非关键字,也需强制加双引号 + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比字段保留两位小数并以%结尾 + 避免与数据库关键字冲突 + + basic_example: | + + + 📌 以下示例严格遵循中的 SQLite 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 ORDERS 表的前100条订单(含中文字段和百分比) + + SELECT * FROM ORDERS LIMIT 100 -- 错误:未加引号、使用星号 + SELECT "订单ID", "金额" FROM "ORDERS" "t1" LIMIT 100 -- 错误:缺少英文别名 + SELECT COUNT("订单ID") FROM "ORDERS" "t1" -- 错误:函数未加别名 + + + SELECT + "t1"."订单ID" AS "order_id", + "t1"."金额" AS "amount", + COUNT("t1"."订单ID") AS "total_orders", + ROUND("t1"."折扣率" * 100, 2) || '%' AS "discount_percent" + FROM "ORDERS" "t1" + LIMIT 100 + + + + + 统计用户表 USERS(含关键字字段user)的活跃占比 + + SELECT user, status FROM USERS -- 错误:未处理关键字和引号 + SELECT "user", ROUND(active_ratio) FROM "USERS" -- 错误:百分比格式错误 + + + SELECT + "u"."user" AS "username", + ROUND("u"."active_ratio" * 100, 2) || '%' AS "active_percent" + FROM "USERS" "u" + WHERE "u"."status" = 1 + + + + + example_engine: SQLite 3.x + example_answer_1: | + {"success":true,"sql":"SELECT \"country_name\", \"continent_name\", \"year\", \"gdp\" FROM \"sample_country_gdp\" ORDER BY \"country_name\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT \"country_name\", \"continent_name\", \"year\", \"gdp\" FROM \"sample_country_gdp\" ORDER BY \"country_name\", \"year\" LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT \"country_name\", \"gdp\" FROM \"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT \"country_name\", \"gdp\" FROM \"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT \"country_name\", \"gdp\" FROM \"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country_name\" = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT \"country_name\", \"gdp\" FROM \"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country_name\" = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/template.yaml b/backend/templates/template.yaml index a447c8287..c642014d9 100644 --- a/backend/templates/template.yaml +++ b/backend/templates/template.yaml @@ -17,7 +17,7 @@ template: 4. 强制检查:应用数据量限制规则(默认限制或用户指定数量) 5. 应用其他规则(引号、别名、格式化等) 6. 强制检查:验证SQL语法是否符合规范 - 7. 确定图表类型(根据规则选择table/column/bar/line/pie) + 7. 确定图表类型(根据规则选择table/column/bar/line/pie/scatter) 8. 确定对话标题 9. 生成JSON结果 10. 强制检查:JSON格式是否正确 @@ -137,8 +137,8 @@ template: 若不能生成,则返回格式如:{{"success":false,"message":"说明无法生成SQL的原因"}} - 如果问题是图表展示相关,可参考的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie), 返回的JSON内chart-type值则为 table/column/bar/line/pie 中的一个 - 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,原始数据查看用 table + 如果问题是图表展示相关,可参考的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)、饼图(pie)或散点图(scatter), 返回的JSON内chart-type值则为 table/column/bar/line/pie/scatter 中的一个 + 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,两个度量间的相关性或分布用 scatter,原始数据查看用 table 图表字段维度与指标数量限制规则 @@ -150,6 +150,12 @@ template: 有分类维度时,只能有一个指标字段(纵轴) 没有分类维度时,可以有多个指标字段 + + 散点图(scatter): + 需要一个横轴度量字段(x)与一个纵轴度量字段(y),用于展示两个数值维度之间的关系或分布 + 可选一个分类字段(series)用于颜色分组;有分类字段时纵轴(y)仅保留一个指标字段 + 无分类字段且存在多个数值指标时,可与折线图类似使用multi-quota展示多系列散点 + 饼图(pie): 必须有一个分类维度字段(扇区) @@ -158,6 +164,11 @@ template: + + 如果图表类型为散点图(scatter) + 在生成的SQL中须包含用于图表横轴(x)与纵轴(y)的两个字段;若有分类字段(series),须包含在查询结果中,排序时优先按横轴字段,其次分类字段 + 除非用户明确要求聚合,否则散点图优先保留明细行,不对度量字段默认使用SUM等聚合(以便展示真实散点分布) + 如果图表类型为柱状图(column)、条形图(bar)或折线图(line) 在生成的SQL中必须指定一个维度字段和一个指标字段,其中维度字段必须参与排序 @@ -173,6 +184,9 @@ template: 在没有明确业务场景说明、或用户没有明确指定不需要聚合的情况下 必须对数值类型指标字段进行聚合计算(默认使用SUM函数) + + 散点图(scatter)一般不适用上一则针对 column/bar/line/pie 的强制SUM聚合规则;若用户明确要求先聚合再作散点,再使用聚合 + 如果问题是图表展示相关且与生成SQL查询无关时,请参考上一次回答的SQL来生成SQL @@ -194,7 +208,7 @@ template: 生成的SQL查询结果可以用来进行图表展示,需要注意排序字段的排序优先级,例如: - - 柱状图或折线图:适合展示在横轴的字段优先排序,若SQL包含分类字段,则分类字段次一级排序 + - 柱状图、折线图或散点图:适合展示在横轴的字段优先排序,若SQL包含分类字段,则分类字段次一级排序 若需关联多表,优先使用中标记为"Primary key"/"ID"/"主键"的字段作为关联条件。 @@ -348,6 +362,9 @@ template: {schema} + + {sample_data} + user: | @@ -396,8 +413,8 @@ template: 以下是你必须遵守的规则和可以参考的基础示例: - 支持的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie), 提供给你的值则为 table/column/bar/line/pie 中的一个,若没有推荐类型,则由你自己选择一个合适的类型。 - 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,原始数据查看用 table + 支持的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)、饼图(pie)或散点图(scatter), 提供给你的值则为 table/column/bar/line/pie/scatter 中的一个,若没有推荐类型,则由你自己选择一个合适的类型。 + 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,两变量关系/分布用 scatter,原始数据查看用 table 不需要你提供创建图表的代码,你只需要负责根据要求生成JSON配置项 @@ -463,6 +480,16 @@ template: 折线图使用一个分类字段(series),一个维度轴字段(x)和一个数值轴字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"。 如果SQL中没有分类列,那么JSON内的series字段不需要出现 + + 如果需要散点图,JSON格式应为(series为可选字段,仅当有分类字段时使用): + {{"type":"scatter", "title": "标题", "axis": {{"x": {{"name":"横轴度量的{lang}名称","value": "SQL 查询横轴对应的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": [{{"name":"纵轴度量的{lang}名称","value": "SQL 查询纵轴对应的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}], "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} + 散点图配置说明: + 1. x轴与y轴:一般为两个数值度量字段,用于展示相关性或分布 + 2. series:可选,用于按类别着色 + 3. 结构与折线图类似;若存在多个指标且无分类字段,可使用multi-quota + 散点图使用可选的分类字段(series),一个横轴字段(x)和一个纵轴字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"(若有)。 + 如果SQL中没有分类列,那么JSON内的series字段不需要出现 + 如果需要饼图,JSON格式应为: {{"type":"pie", "title": "标题", "axis": {{"y": {{"name":"数值轴的{lang}名称","value":"SQL 查询数值的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} @@ -478,7 +505,7 @@ template: 如果SQL查询结果中存在可用于数据分类的字段(如国家、产品类型等),则必须提供series配置。如果不存在,则无需在JSON中包含series字段。 - 对于柱状图/条形图/折线图: + 对于柱状图/条形图/折线图/散点图: 1. 如果SQL查询中存在多个指标字段(如"收入"、"支出"、"利润"等数值字段)且不存在分类字段,则必须提供multi-quota配置,形如:"multi-quota":{{"name":"指标类型","value":["指标字段1","指标字段2",...]}} 2. 如果SQL查询中存在多个指标字段且同时存在分类字段,则以分类字段为主,选取多指标字段中的其中一个作为指标即可,不需要multi-quota配置 3. 如果只有一个指标字段,无论是否存在分类字段,都不需要multi-quota配置 @@ -553,7 +580,7 @@ template: 您的任务是根据给定的表结构,用户问题以及以往用户提问,推测用户接下来可能提问的1-{articles_number}个问题。 请遵循以下规则: - 推测的问题需要与提供的表结构相关,生成的提问例子如:["查询所有用户数据","使用饼图展示各产品类型的占比","使用折线图展示销售额趋势",...] - - 推测问题如果涉及图形展示,支持的图形类型为:表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie) + - 推测问题如果涉及图形展示,支持的图形类型为:表格(table)、柱状图(column)、条形图(bar)、折线图(line)、饼图(pie)或散点图(scatter) - 推测的问题不能与当前用户问题重复 - 推测的问题必须与给出的表结构相关 - 若有以往用户提问列表,则根据以往用户提问列表,推测用户最频繁提问的问题,加入到你生成的推测问题中 diff --git a/frontend/index.html b/frontend/index.html index 97c846b88..7cfdc7c5a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,21 @@ SQLBot + + + +
diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 2a5870632..06276f2e4 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -492,6 +492,13 @@ export const chatApi = { recentQuestions: (datasource_id?: number): Promise => { return request.get(`/chat/recent_questions/${datasource_id}`) }, + popularQuestions: ( + limit = 8 + ): Promise< + Array<{ datasource_id: number; datasource_name: string; question: string; count: number }> + > => { + return request.get('/chat/popular_questions', { params: { limit } }) + }, checkLLMModel: () => request.get('/system/aimodel/default', { requestOptions: { silent: true } }), export2Excel: (record_id: number | undefined, chat_id: any) => request.get(`/chat/record/${record_id}/excel/export/${chat_id}`, { diff --git a/frontend/src/assets/datasource/icon_hive.png b/frontend/src/assets/datasource/icon_hive.png new file mode 100644 index 000000000..51b996f5e Binary files /dev/null and b/frontend/src/assets/datasource/icon_hive.png differ diff --git a/frontend/src/assets/datasource/icon_sqlite.png b/frontend/src/assets/datasource/icon_sqlite.png new file mode 100644 index 000000000..1a198d0b4 Binary files /dev/null and b/frontend/src/assets/datasource/icon_sqlite.png differ diff --git a/frontend/src/assets/svg/chart/icon_scatter_outlined.svg b/frontend/src/assets/svg/chart/icon_scatter_outlined.svg new file mode 100644 index 000000000..3debe5f6c --- /dev/null +++ b/frontend/src/assets/svg/chart/icon_scatter_outlined.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/components/layout/LayoutDsl.vue b/frontend/src/components/layout/LayoutDsl.vue index 3b8c7017a..ab514f664 100644 --- a/frontend/src/components/layout/LayoutDsl.vue +++ b/frontend/src/components/layout/LayoutDsl.vue @@ -1,10 +1,9 @@ -
+
+
+ +
{
- +
-
+
+
@@ -237,7 +294,7 @@ onBeforeMount(() => { .system-layout { width: 100vw; height: 100vh; - background-color: #f1f4f3; + background-color: var(--color-canvas-parchment); display: flex; @keyframes rotate { @@ -255,12 +312,17 @@ onBeforeMount(() => { padding: 16px; position: relative; min-width: 240px; + background-color: var(--color-canvas); + border-right: 1px solid var(--color-hairline); .default-sqlbot { display: flex; align-items: center; - font-weight: 500; - font-size: 16px; + font-family: var(--font-sans); + font-weight: 600; + font-size: 17px; + letter-spacing: -0.374px; + color: var(--color-ink); cursor: pointer; margin-bottom: 12px; .collapse-icon { @@ -271,8 +333,11 @@ onBeforeMount(() => { .sys-management { display: flex; align-items: center; - font-weight: 500; - font-size: 16px; + font-family: var(--font-sans); + font-weight: 600; + font-size: 17px; + letter-spacing: -0.374px; + color: var(--color-ink); cursor: pointer; margin-bottom: 12px; .collapse-icon { @@ -292,19 +357,19 @@ onBeforeMount(() => { display: flex; align-items: center; justify-content: center; - border-radius: 6px; + border-radius: 8px; height: 40px; cursor: pointer; &:not(.collapse) { - background: #1f23290a; - border: 1px solid #d9dcdf; + background: var(--overlay-hover); + border: 1px solid var(--color-hairline); } &:hover { - background-color: #1f23291a; + background-color: var(--overlay-hover); } &:active { - background-color: #1f232926; + background-color: var(--overlay-pressed); } .ed-icon { margin-right: 4.95px; @@ -319,16 +384,16 @@ onBeforeMount(() => { .fold { cursor: pointer; margin-left: auto; - border-radius: 6px; + border-radius: 8px; width: 40px; height: 40px; &:hover, &:focus { - background: #1f23291a; + background: var(--overlay-hover); } &:active { - background: #1f232933; + background: var(--overlay-strong); } } } @@ -338,7 +403,6 @@ onBeforeMount(() => { width: 64px; min-width: 64px; padding: 16px 12px; - // animation: rotate 0.1s ease-in-out; .ed-menu--collapse { --ed-menu-icon-width: 32px; @@ -380,9 +444,10 @@ onBeforeMount(() => { width: 100%; height: 100%; padding: 16px 24px; - background-color: #fff; - border-radius: 12px; - box-shadow: 0px 2px 4px 0px #1f23291f; + background-color: var(--color-canvas); + border: 1px solid var(--color-hairline); + border-radius: 18px; + box-shadow: none; overflow-x: auto; &:has(.no-padding) { @@ -390,5 +455,100 @@ onBeforeMount(() => { } } } + + &.system-layout--chat { + align-items: stretch; + } + + .layout-column-resizer { + width: 6px; + flex-shrink: 0; + cursor: col-resize; + align-self: stretch; + margin: 8px 0; + border-radius: 4px; + background: transparent; + transition: background 0.15s; + + &:hover { + background: var(--overlay-hover); + } + } + + .left-side--chat { + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + + .left-side-nav-block { + flex-shrink: 0; + flex: 0 1 auto; + max-height: 46%; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + position: relative; + z-index: 5; + } + + .layout-chat-history-shell { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + padding-top: 4px; + position: relative; + z-index: 1; + } + + .layout-chat-history-divider { + flex-shrink: 0; + height: 1px; + margin: 0 12px; + background: var(--color-hairline); + } + + .layout-chat-history-heading { + flex-shrink: 0; + padding: 8px 16px 4px; + font-size: 12px; + font-weight: 600; + line-height: 18px; + color: var(--color-muted); + letter-spacing: 0.02em; + } + + .layout-chat-history { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + + & > * { + flex: 1; + min-height: 0; + width: 100%; + } + } + + .bottom.bottom--flow { + position: static !important; + width: 100% !important; + left: auto !important; + bottom: auto !important; + margin-top: auto; + padding-top: 8px; + } + } + + .right-main--chat { + flex: 1; + width: auto !important; + min-width: 0; + min-height: 0; + } } diff --git a/frontend/src/components/layout/Menu.vue b/frontend/src/components/layout/Menu.vue index 731d1027a..e8cdf93d4 100644 --- a/frontend/src/components/layout/Menu.vue +++ b/frontend/src/components/layout/Menu.vue @@ -87,59 +87,79 @@ const routerList = computed(() => { border-right: none; .ed-menu-item { height: 40px !important; - border-radius: 6px !important; + border-radius: 8px !important; margin-bottom: 2px; + font-size: 14px; + font-weight: 400; + letter-spacing: -0.224px; + color: var(--color-muted); &.is-active { - background-color: #fff !important; - border-radius: 6px; - font-weight: 500; + background-color: rgba(0, 102, 204, 0.1) !important; + border-radius: 8px; + font-weight: 600; + color: var(--color-ink); } } .ed-sub-menu .ed-sub-menu__title { - border-radius: 6px; + border-radius: 8px; + color: var(--color-muted); + font-size: 14px; + font-weight: 400; + letter-spacing: -0.224px; } .ed-sub-menu.is-active:not(.is-opened) { .ed-sub-menu__title { - background-color: #fff !important; - color: var(--ed-color-primary) !important; - font-weight: 500; + background-color: rgba(0, 102, 204, 0.1) !important; + color: #0066cc !important; + font-weight: 600; } } .ed-sub-menu.is-active.is-opened { .ed-sub-menu__title { - color: var(--ed-color-primary) !important; - font-weight: 500; + color: #0066cc !important; + font-weight: 600; } } .ed-sub-menu .ed-icon { margin-right: 8px; + color: var(--color-muted); } } .ed-popper.is-light:has(.ed-menu--popup) { - border: 1px solid #dee0e3; - border-radius: 6px; - box-shadow: 0px 4px 8px 0px #1f23291a; - background: #eff1f0; + border: 1px solid var(--color-hairline); + border-radius: 8px; + box-shadow: none; + background: var(--color-canvas); overflow: hidden; } .ed-menu--popup { padding: 8px; - background: #eff1f0; + background: var(--color-canvas); .ed-menu-item { padding: 9px 16px; height: 40px !important; - border-radius: 6px; + border-radius: 8px; + font-size: 14px; + font-weight: 400; + letter-spacing: -0.224px; + color: var(--color-muted); &.is-active { - background-color: #fff !important; - font-weight: 500; + background-color: rgba(0, 102, 204, 0.1) !important; + font-weight: 600; + color: var(--color-ink); } } } + +/* 问数侧栏折叠时,设置等子菜单浮层需盖过下方历史区与主内容区 */ +.menu-left-sub-popup { + z-index: 6000 !important; +} .ed-sub-menu { .subTitleMenu { display: none; @@ -147,7 +167,7 @@ const routerList = computed(() => { } .ed-menu--popup-container .subTitleMenu { - color: #646a73 !important; + color: var(--color-muted) !important; pointer-events: none; } diff --git a/frontend/src/components/layout/MenuItem.vue b/frontend/src/components/layout/MenuItem.vue index 279030569..3b932150e 100644 --- a/frontend/src/components/layout/MenuItem.vue +++ b/frontend/src/components/layout/MenuItem.vue @@ -82,7 +82,11 @@ const MenuItem = defineComponent({ const icon = route.path.startsWith(path) ? iconActive : iconDeActive return h( ElSubMenu, - { index: path, onClick: () => handleMenuClick(props.menu) }, + { + index: path, + onClick: () => handleMenuClick(props.menu), + popperClass: 'menu-left-sub-popup', + }, { title: () => titleWithIcon({ title, icon }), default: () => [ diff --git a/frontend/src/components/layout/Person.vue b/frontend/src/components/layout/Person.vue index ae0448e26..e451a02b1 100644 --- a/frontend/src/components/layout/Person.vue +++ b/frontend/src/components/layout/Person.vue @@ -8,6 +8,7 @@ import icon_maybe_outlined from '@/assets/svg/icon-maybe_outlined.svg' import icon_key_outlined from '@/assets/svg/icon-key_outlined.svg' import icon_api_key from '@/assets/svg/icon-api_key.svg' import icon_translate_outlined from '@/assets/svg/icon_translate_outlined.svg' +import icon_replace_outlined from '@/assets/svg/icon_replace_outlined.svg' import icon_logout_outlined from '@/assets/svg/icon_logout_outlined.svg' import icon_right_outlined from '@/assets/svg/icon_right_outlined.svg' import AboutDialog from '@/components/about/index.vue' @@ -20,6 +21,13 @@ import { useUserStore } from '@/stores/user' import { userApi } from '@/api/auth' import { toLoginPage } from '@/utils/utils' import { useCache } from '@/utils/useCache' +import { ElMessage } from 'element-plus-secondary' +import { + applyUiTheme, + getStoredUiTheme, + UI_THEME_ORDER, + type UiThemeId, +} from '@/utils/uiTheme' const { wsCache } = useCache() const router = useRouter() @@ -69,6 +77,17 @@ const languageList = computed(() => [ ]) const popoverRef = ref() +const currentUiTheme = ref(getStoredUiTheme()) +const themeList = computed(() => + UI_THEME_ORDER.map((id) => ({ id, name: t(`theme.${id}`) })) +) + +function selectUiTheme(id: UiThemeId) { + applyUiTheme(id) + currentUiTheme.value = id + ElMessage.success(t('common.switch_success')) +} + const toSystem = () => { popoverRef.value.hide() router.push('/system') @@ -137,7 +156,7 @@ const logout = async () => {
{{ account }}
- +
{{ $t('common.system_manage') }}
@@ -154,7 +173,7 @@ const logout = async () => {
API Key
- +