@sableui/react 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/dist/{_contract-CYScOdf5.d.ts → _contract-o3tUc8Ig.d.ts} +1 -1
- package/dist/{chunk-J23ELQQZ.js → chunk-AOWBYEQ6.js} +4177 -1917
- package/dist/{chunk-RWP4XBSK.js → chunk-KFZ43RKU.js} +1 -1
- package/dist/{chunk-JI7WJP2L.js → chunk-X276MF2W.js} +5 -0
- package/dist/foundations/index.js +1 -1
- package/dist/index.d.ts +155 -101
- package/dist/index.js +582 -2778
- package/dist/ios/index.d.ts +6 -1
- package/dist/ios/index.js +4 -4
- package/dist/ios/preview.js +1 -1
- package/dist/ios/styles.css +20 -6
- package/dist/patterns/index.js +1683 -469
- package/dist/presets/index.d.ts +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/styles.css +238 -40
- package/dist/{theme-provider-BWcizhAt.d.ts → theme-provider-BE-KqUG-.d.ts} +1 -1
- package/dist/themes/atelier/atelier.css +3 -0
- package/dist/themes/atelier/index.d.ts +1 -1
- package/dist/themes/base/base.css +11 -4
- package/dist/themes/base/index.d.ts +1 -1
- package/dist/themes/ember/ember.css +3 -0
- package/dist/themes/ember/index.d.ts +1 -1
- package/dist/themes/index.d.ts +2 -2
- package/dist/themes/meridian/index.d.ts +1 -1
- package/dist/themes/meridian/meridian.css +10 -5
- package/dist/themes/poppy/index.d.ts +1 -1
- package/dist/themes/poppy/poppy.css +16 -0
- package/dist/themes/prism/index.d.ts +1 -1
- package/dist/themes/prism/prism.css +4 -1
- package/dist/themes/verdant/index.d.ts +1 -1
- package/dist/themes/verdant/verdant.css +7 -2
- package/llms/components/bubble.md +5 -5
- package/llms/components/checkbox-card.md +1 -1
- package/llms/components/context-card.md +2 -2
- package/llms/components/dialog.md +1 -1
- package/llms/components/empty-state.md +4 -4
- package/llms/components/entity.md +1 -1
- package/llms/components/fieldset.md +1 -1
- package/llms/components/kbd.md +18 -6
- package/llms/components/pill.md +1 -1
- package/llms/components/side-nav.md +1 -1
- package/llms/components/skeleton.md +1 -1
- package/llms/components/theme-selector.md +1 -1
- package/llms/foundations/shape.md +1 -1
- package/llms/patterns/auth-page.md +132 -42
- package/llms/patterns/brand-landing-page.md +271 -175
- package/llms/patterns/chat-panel.md +120 -39
- package/llms/patterns/dashboard-page.md +277 -65
- package/llms/patterns/feed-page.md +245 -64
- package/llms/patterns/landing-page.md +473 -75
- package/llms/patterns/list-detail-page.md +385 -68
- package/llms/patterns/settings-page.md +279 -67
- package/llms/reference/token-intent.md +4 -2
- package/llms/reference/utilities.md +20 -5
- package/llms-full.txt +2244 -727
- package/llms.txt +14 -15
- package/package.json +1 -1
- package/registry.json +261 -238
- package/targets/ios.json +4 -15
- package/llms/components/description.md +0 -96
package/llms-full.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sable (@sableui/react)
|
|
2
2
|
|
|
3
|
-
> An owned, themeable React component system. Default theme: **Base** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter.
|
|
3
|
+
> An owned, themeable React component system. Default theme: **Base** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter. 96 components, 13 layout primitives, and a **patterns** tier of canonical page shells. The full, per-component contract is the generated `registry.json`.
|
|
4
4
|
|
|
5
5
|
## Start here — brief, composition, evidence
|
|
6
6
|
|
|
@@ -2573,7 +2573,7 @@ Each step is 4px apart, so common insets land on a real step. Themes and the cus
|
|
|
2573
2573
|
## Rules
|
|
2574
2574
|
|
|
2575
2575
|
1. **Nested corners are concentric: inner radius = outer radius − inset. Write it as rounded-concentric-<outer>/<inset>, not a hardcoded step.** A 12px popover with 4px padding takes 8px items. The utility computes the subtraction from the live tokens, so the pair stays concentric when a theme or the customizer rescales the radius ladder; a hardcoded rounded-sm drifts.
|
|
2576
|
-
2. **A list of rows inside a rounded popup uses popup-inset on the popup, rounded-popup-item on the rows and popup-bleed on a separator.** All three derive from the popup's radius: the inset grows to a third of it (never below --spacing(1), so density moves it too), the rows take what is left, and a separator bleeds by exactly the inset. A rounder look gets more room instead of rows that round into pills
|
|
2576
|
+
2. **A list of rows inside a rounded popup uses popup-inset on the popup, rounded-popup-item on the rows and popup-bleed on a separator.** All three derive from the popup's radius: the inset grows to a third of it (never below --spacing(1), so density moves it too), the rows take what is left after the inset and the 1px border, and a separator bleeds by exactly the inset. A rounder look gets more room instead of rows that round into pills; the default ladder lands on a 4px inset and 7px rows. *Enforced by sable-lint: popup-separator-bleed.*
|
|
2577
2577
|
3. **A Button, Pill or Badge gets its look from a variant, never from override classes.** border, bg, rounded, ring and shadow classes on a component fight the variant's own hover, selected and focus states, and give one call site a look no other site can ask for. Use the variant that exists (Button variant="floating" over content, "on-color" on a fill; Pill appearance="outline"; Badge appearance and tone) or add one to the component. *Enforced by sable-lint: component-restyle.*
|
|
2578
2578
|
4. **The outer radius is always at least the inner radius.** If the inset is larger than the outer radius, square the inner element or step the container up.
|
|
2579
2579
|
5. **Never mix radii within one component.** A component reads as one object when its corners agree.
|
|
@@ -2897,68 +2897,158 @@ The region is in the accessibility tree before its text changes, so the change i
|
|
|
2897
2897
|
|
|
2898
2898
|
# AuthPage
|
|
2899
2899
|
|
|
2900
|
-
|
|
2900
|
+
Split sign-in page: a form column (brand mark, heading, email + password, primary sign-in, passkey and wallet alternatives, sign-up switch, terms) beside a brand panel painted from the theme's tones with a featured release card. Below lg the panel drops away and the form is the whole screen.
|
|
2901
2901
|
|
|
2902
2902
|
**Intent:** Reach for this for sign-in, sign-up, forgot-password, and any single-purpose one-column form route (invite accept, unsubscribe). Not for multi-section forms (use SettingsPage).
|
|
2903
2903
|
|
|
2904
2904
|
## Notes
|
|
2905
2905
|
|
|
2906
|
-
-
|
|
2907
|
-
-
|
|
2908
|
-
-
|
|
2909
|
-
-
|
|
2910
|
-
-
|
|
2906
|
+
- The form column is a `form`-capped Region (480px), so the controls keep the same measure whether the panel is present or not.
|
|
2907
|
+
- No card around the form: the page itself is the surface, and the brand panel carries the identity. A card on a blank page reads as a template.
|
|
2908
|
+
- The heading is `text-heading-1` in an h1 — it is the page outline, so it takes a heading tier, not a card title.
|
|
2909
|
+
- Brand mark, form and terms are a three-part column: mark pinned to the top, form vertically centred in the remaining space, terms pinned to the bottom.
|
|
2910
|
+
- Forgot password sits under the password field, right-aligned, before the submit button — so it comes right after the field in tab order. Field has no label-row action slot yet.
|
|
2911
|
+
- One primary action. Passkey and wallet are outline buttons under an `or` Separator; switching to sign-up is a quiet `link-underline` link.
|
|
2912
|
+
- Validation uses Field `validate` for product copy instead of the browser's generic required message. A rejected sign-in comes back through Form `errors` on the password field; the submit Button shows `loading` while the request is in flight.
|
|
2913
|
+
- The brand panel artwork is a mesh of the theme's decorative tones over `--primary`, so it re-paints for every theme and mode. The relative-colour `oklch(from var(--tone-*) …)` lift needs an `inline-color` disable with a reason.
|
|
2914
|
+
- The panel is `hidden lg:flex`: on a phone the form is the whole screen, with no decorative scroll above it.
|
|
2911
2915
|
|
|
2912
2916
|
## Accessibility
|
|
2913
2917
|
|
|
2914
|
-
- The route renders inside <main> with
|
|
2915
|
-
- Field auto-wires labels, describedby, and invalid state onto the inputs.
|
|
2916
|
-
- Autocomplete attributes (email
|
|
2918
|
+
- The route renders inside <main> with one h1; the brand panel is an <aside> labelled "Featured release".
|
|
2919
|
+
- Field auto-wires labels, describedby, and invalid state onto the inputs; on submit, the first invalid field is focused.
|
|
2920
|
+
- Autocomplete attributes (`email webauthn`, `current-password`) keep password managers and passkey autofill working.
|
|
2921
|
+
- The Forgot password and Create one links use `hit-target` to meet the 44px touch floor without changing layout.
|
|
2922
|
+
- The artwork swatches are aria-hidden decoration; the brand link's accessible name is the wordmark.
|
|
2917
2923
|
|
|
2918
2924
|
## Source
|
|
2919
2925
|
|
|
2920
2926
|
```tsx
|
|
2921
|
-
import {
|
|
2927
|
+
import { useState } from "react"
|
|
2928
|
+
import { Avatar, Badge, Button, Card, CardContent, Entity, Field, Form, Icon, Input, Region, Separator, Stack } from "@sableui/react"
|
|
2929
|
+
|
|
2930
|
+
// A soft mesh of the theme's decorative tones over its brand colour, so the
|
|
2931
|
+
// artwork follows every theme and mode. Each tone is lifted from its text
|
|
2932
|
+
// strength (dark, restrained) to an art strength.
|
|
2933
|
+
const artwork = [
|
|
2934
|
+
// design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
|
|
2935
|
+
"radial-gradient(in oklab at 18% 22%, oklch(from var(--tone-info) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 58%)",
|
|
2936
|
+
// design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
|
|
2937
|
+
"radial-gradient(in oklab at 82% 18%, oklch(from var(--tone-collectible) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 55%)",
|
|
2938
|
+
// design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
|
|
2939
|
+
"radial-gradient(in oklab at 70% 88%, oklch(from var(--tone-edition) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 60%)",
|
|
2940
|
+
// design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
|
|
2941
|
+
"radial-gradient(in oklab at 12% 92%, oklch(from var(--tone-standard) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 55%)",
|
|
2942
|
+
"var(--primary)",
|
|
2943
|
+
].join(", ")
|
|
2922
2944
|
|
|
2923
2945
|
export function AuthPage() {
|
|
2946
|
+
const [pending, setPending] = useState(false)
|
|
2947
|
+
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
2948
|
+
|
|
2924
2949
|
return (
|
|
2925
|
-
<main className="grid min-h-dvh
|
|
2926
|
-
<Region max="form" safeArea={["top", "bottom"]}>
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
<
|
|
2937
|
-
<
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2950
|
+
<main className="grid min-h-dvh bg-background lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
|
|
2951
|
+
<Region max="form" safeArea={["top", "bottom"]} className="flex flex-col">
|
|
2952
|
+
{/* safeArea owns the region's own padding, so the breathing room sits one level in. */}
|
|
2953
|
+
<Stack gap={0} className="flex-1 py-8 sm:py-10">
|
|
2954
|
+
<a href="/" className="flex items-center gap-2.5 self-start rounded-sm motion-interactive motion-press">
|
|
2955
|
+
{/* The same mark the app shell's sidebar and header carry. */}
|
|
2956
|
+
<Icon name="gem" variant="solid" className="text-2xl" />
|
|
2957
|
+
<span className="text-title-lg">Atelier</span>
|
|
2958
|
+
</a>
|
|
2959
|
+
|
|
2960
|
+
<Stack gap={4} justify="center" className="flex-1 py-12">
|
|
2961
|
+
<Stack gap={1.5}>
|
|
2962
|
+
<h1 className="text-heading-1">Welcome back</h1>
|
|
2963
|
+
<p className="text-body-lg text-muted-foreground">
|
|
2964
|
+
Sign in to collect new releases and manage your editions.
|
|
2965
|
+
</p>
|
|
2966
|
+
</Stack>
|
|
2967
|
+
|
|
2968
|
+
<Form
|
|
2969
|
+
className="gap-5"
|
|
2970
|
+
errors={errors}
|
|
2971
|
+
onFormSubmit={() => {
|
|
2972
|
+
setPending(true)
|
|
2973
|
+
// Replace with your auth call; a rejected sign-in comes back as a field error.
|
|
2974
|
+
setTimeout(() => {
|
|
2975
|
+
setPending(false)
|
|
2976
|
+
setErrors({ password: "That email and password don’t match. Try again or reset your password." })
|
|
2977
|
+
}, 900)
|
|
2978
|
+
}}
|
|
2979
|
+
>
|
|
2980
|
+
<Field label="Email" name="email" validate={(value) => (value ? null : "Enter your email address.")}>
|
|
2981
|
+
<Input type="email" autoComplete="email webauthn" placeholder="you@example.com" />
|
|
2982
|
+
</Field>
|
|
2983
|
+
<Stack gap={1.5}>
|
|
2984
|
+
<Field label="Password" name="password" validate={(value) => (value ? null : "Enter your password.")}>
|
|
2985
|
+
<Input type="password" autoComplete="current-password" />
|
|
2943
2986
|
</Field>
|
|
2944
|
-
<
|
|
2945
|
-
|
|
2946
|
-
</
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2987
|
+
<a href="/forgot-password" className="hit-target self-end text-body-sm text-muted-foreground link-underline motion-interactive">
|
|
2988
|
+
Forgot password?
|
|
2989
|
+
</a>
|
|
2990
|
+
</Stack>
|
|
2991
|
+
<Button type="submit" size="cta" loading={pending} loadingLabel="Signing in" className="w-full">
|
|
2992
|
+
Sign in
|
|
2993
|
+
</Button>
|
|
2994
|
+
</Form>
|
|
2995
|
+
|
|
2996
|
+
<Separator label="or" />
|
|
2997
|
+
|
|
2998
|
+
<Stack gap={1.5}>
|
|
2999
|
+
<Button variant="outline" size="cta" className="w-full">
|
|
3000
|
+
Sign in with a passkey
|
|
3001
|
+
</Button>
|
|
3002
|
+
<Button variant="outline" size="cta" className="w-full">
|
|
3003
|
+
Connect a wallet
|
|
3004
|
+
</Button>
|
|
3005
|
+
</Stack>
|
|
3006
|
+
|
|
3007
|
+
<p className="text-body-md text-muted-foreground">
|
|
3008
|
+
No account?{" "}
|
|
3009
|
+
<a href="/signup" className="hit-target link-underline motion-interactive text-foreground">
|
|
3010
|
+
Create one
|
|
3011
|
+
</a>
|
|
3012
|
+
</p>
|
|
3013
|
+
</Stack>
|
|
3014
|
+
|
|
3015
|
+
<p className="text-body-xs text-muted-foreground">
|
|
3016
|
+
By signing in you agree to the{" "}
|
|
3017
|
+
<a href="/terms" className="link-underline motion-interactive">
|
|
3018
|
+
Terms
|
|
3019
|
+
</a>{" "}
|
|
3020
|
+
and{" "}
|
|
3021
|
+
<a href="/privacy" className="link-underline motion-interactive">
|
|
3022
|
+
Privacy Policy
|
|
2958
3023
|
</a>
|
|
3024
|
+
.
|
|
2959
3025
|
</p>
|
|
2960
3026
|
</Stack>
|
|
2961
3027
|
</Region>
|
|
3028
|
+
|
|
3029
|
+
{/* The brand panel is decoration: on a phone the form is the whole screen. */}
|
|
3030
|
+
<aside aria-label="Featured release" className="hidden p-3 lg:flex">
|
|
3031
|
+
<div className="flex flex-1 flex-col justify-end rounded-xl p-8" style={{ background: artwork }}>
|
|
3032
|
+
<Card className="max-w-md">
|
|
3033
|
+
<CardContent>
|
|
3034
|
+
<Stack gap={3}>
|
|
3035
|
+
<Badge tone="edition" className="self-start">
|
|
3036
|
+
Edition of 50
|
|
3037
|
+
</Badge>
|
|
3038
|
+
<p className="text-title-lg">
|
|
3039
|
+
“Every edition is a conversation with whoever holds it next.”
|
|
3040
|
+
</p>
|
|
3041
|
+
<Entity
|
|
3042
|
+
className="p-0"
|
|
3043
|
+
leading={<Avatar fallback="MO" />}
|
|
3044
|
+
title="Mara Okafor"
|
|
3045
|
+
subtitle="Untitled (gold rush), 2026"
|
|
3046
|
+
/>
|
|
3047
|
+
</Stack>
|
|
3048
|
+
</CardContent>
|
|
3049
|
+
</Card>
|
|
3050
|
+
</div>
|
|
3051
|
+
</aside>
|
|
2962
3052
|
</main>
|
|
2963
3053
|
)
|
|
2964
3054
|
}
|
|
@@ -2968,80 +3058,114 @@ export function AuthPage() {
|
|
|
2968
3058
|
|
|
2969
3059
|
# BrandLandingPage
|
|
2970
3060
|
|
|
2971
|
-
High-fidelity brand/product landing page: pill navigation, editorial hero type, a dark product scene,
|
|
3061
|
+
High-fidelity brand/product landing page: pill navigation, editorial hero type, a dark product scene, a value section, a how-it-works sequence, a customer story with outcomes, a closing CTA band, and a complete branded footer.
|
|
2972
3062
|
|
|
2973
3063
|
**Intent:** Use this for a polished product marketing route that still needs to show a credible app surface. It is warmer and more tactile than the canonical LandingPage, but avoids trend-driven neon and generic hype cues.
|
|
2974
3064
|
|
|
2975
3065
|
## Notes
|
|
2976
3066
|
|
|
2977
|
-
-
|
|
3067
|
+
- The story runs promise → product → value → process → proof → ask: hero, product scene, 'Why Cove' value cards, a three-step how-it-works list, a customer story with outcome figures, then one closing CTA. Each section adds new evidence; no number is repeated from the product scene.
|
|
3068
|
+
- One display headline owns the first viewport; supporting copy stays in body-lg at prose measure. Section headings are heading-1 under a `text-overline` eyebrow.
|
|
2978
3069
|
- The nav spends one compact pill surface, with a clear brand signal and two primary paths: sign in and request access.
|
|
2979
|
-
- The product scene
|
|
2980
|
-
- The
|
|
2981
|
-
-
|
|
2982
|
-
-
|
|
2983
|
-
-
|
|
3070
|
+
- The product scene and the closing CTA band are dark islands: a `dark` class on the wrapper re-scopes every semantic token, so surfaces, muted ink, Badge tones and Buttons take the theme's own dark values in either page mode. Never fake a dark panel with overlay ink at reduced opacity — status tones tuned for a light page vanish on it.
|
|
3071
|
+
- The scene is a picture of the product: `aria-hidden` + `inert` keep its sample button and figures out of the tab order and the outline. On narrow screens it drops the side column and keeps the signal map, so it scales instead of stacking into a second page.
|
|
3072
|
+
- The chart keeps past weeks on the quiet `secondary` fill and gives the current week the one accent, so the signal reads at a glance.
|
|
3073
|
+
- Value cards are real Cards with a neutral `sable-well` icon tile; the customer story is a Card rendered as a figure, with outcome figures split by Separator. The scene's nested panels take `rounded-concentric-xl/<inset>` from the shell's radius, so the corners stay parallel at any radius preset.
|
|
3074
|
+
- The footer closes the page with brand reassurance, a plain status Badge, a three-column sitemap (three columns at every width), a digest signup, and legal links.
|
|
3075
|
+
- All figures, names and the testimonial are sample content; replace them with real, verifiable claims.
|
|
2984
3076
|
|
|
2985
3077
|
## Accessibility
|
|
2986
3078
|
|
|
2987
3079
|
- The brand nav is a labeled nav landmark with real links and visible text.
|
|
3080
|
+
- Each content section is a labelled region (by its h2, or aria-label for the customer story); the decorative product scene is hidden from assistive tech and inert.
|
|
3081
|
+
- The how-it-works steps are an ordered list; the testimonial is a figure with a blockquote and a figcaption naming the speaker.
|
|
2988
3082
|
- The footer is a real footer landmark with a labeled footer nav, visible link text, and a labeled email field.
|
|
2989
|
-
- TextReveal preserves the full headline as one accessible label and honors reduced-motion policy.
|
|
2990
|
-
- Status dots are paired with visible text labels so color is never the only signal.
|
|
3083
|
+
- TextReveal preserves the full headline as one accessible label and honors reduced-motion policy; Reveal shows the value cards immediately when motion is off.
|
|
3084
|
+
- Status dots and the status Badge are paired with visible text labels so color is never the only signal.
|
|
2991
3085
|
|
|
2992
3086
|
## Source
|
|
2993
3087
|
|
|
2994
3088
|
```tsx
|
|
2995
|
-
import { Badge, Button, Card,
|
|
3089
|
+
import { Avatar, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Grid, Icon, Input, Region, Reveal, Row, Separator, Stack, StatusDot, TextReveal } from "@sableui/react"
|
|
2996
3090
|
|
|
2997
|
-
const navItems = [
|
|
3091
|
+
const navItems = [
|
|
3092
|
+
{ label: "Platform", href: "#platform" },
|
|
3093
|
+
{ label: "How it works", href: "#how-it-works" },
|
|
3094
|
+
{ label: "Customers", href: "#customers" },
|
|
3095
|
+
{ label: "Pricing", href: "/pricing" },
|
|
3096
|
+
]
|
|
2998
3097
|
|
|
2999
|
-
const
|
|
3000
|
-
{
|
|
3001
|
-
{
|
|
3002
|
-
{
|
|
3098
|
+
const spaces = [
|
|
3099
|
+
{ name: "Waypoint", value: "98.4%", label: "clear routes" },
|
|
3100
|
+
{ name: "Fielddesk", value: "12", label: "open holds" },
|
|
3101
|
+
{ name: "Archive", value: "4.8k", label: "sealed records" },
|
|
3003
3102
|
]
|
|
3004
3103
|
|
|
3005
|
-
const
|
|
3104
|
+
const routes = [
|
|
3006
3105
|
{ label: "Intake", state: "Open", tone: "info" as const },
|
|
3007
3106
|
{ label: "Review", state: "Live", tone: "edition" as const },
|
|
3008
3107
|
{ label: "Archive", state: "Safe", tone: "success" as const },
|
|
3009
3108
|
]
|
|
3010
3109
|
|
|
3110
|
+
// Weekly approvals: past weeks recede, the current week carries the signal.
|
|
3011
3111
|
const bars = [
|
|
3012
|
-
{ height: "38%",
|
|
3013
|
-
{ height: "56%",
|
|
3014
|
-
{ height: "44%",
|
|
3015
|
-
{ height: "68%",
|
|
3016
|
-
{ height: "52%",
|
|
3017
|
-
{ height: "78%",
|
|
3018
|
-
{ height: "60%",
|
|
3019
|
-
{
|
|
3020
|
-
{
|
|
3112
|
+
{ week: "w1", height: "38%", current: false },
|
|
3113
|
+
{ week: "w2", height: "56%", current: false },
|
|
3114
|
+
{ week: "w3", height: "44%", current: false },
|
|
3115
|
+
{ week: "w4", height: "68%", current: false },
|
|
3116
|
+
{ week: "w5", height: "52%", current: false },
|
|
3117
|
+
{ week: "w6", height: "78%", current: false },
|
|
3118
|
+
{ week: "w7", height: "60%", current: false },
|
|
3119
|
+
{ week: "w8", height: "72%", current: false },
|
|
3120
|
+
{ week: "w9", height: "88%", current: true },
|
|
3021
3121
|
]
|
|
3022
3122
|
|
|
3023
|
-
const
|
|
3123
|
+
const sceneMetrics = [
|
|
3124
|
+
{ label: "Cleared", value: "1.8M", detail: "last 30 days" },
|
|
3125
|
+
{ label: "Review", value: "42s", detail: "median path" },
|
|
3126
|
+
{ label: "Spaces", value: "284", detail: "active teams" },
|
|
3127
|
+
]
|
|
3128
|
+
|
|
3129
|
+
const values = [
|
|
3024
3130
|
{
|
|
3025
3131
|
icon: "lock" as const,
|
|
3026
3132
|
title: "Private by default",
|
|
3027
|
-
body: "
|
|
3133
|
+
body: "Roles, approvals, and handoff history stay with the people who need them. No shared inboxes, no forwarded screenshots.",
|
|
3028
3134
|
},
|
|
3029
3135
|
{
|
|
3030
|
-
icon: "
|
|
3031
|
-
title: "
|
|
3032
|
-
body: "
|
|
3136
|
+
icon: "bell" as const,
|
|
3137
|
+
title: "Signals before surprises",
|
|
3138
|
+
body: "Cove watches every route and surfaces stalled reviews and expiring approvals while there is still time to act.",
|
|
3033
3139
|
},
|
|
3034
3140
|
{
|
|
3035
|
-
icon: "
|
|
3036
|
-
title: "
|
|
3037
|
-
body: "
|
|
3141
|
+
icon: "clock" as const,
|
|
3142
|
+
title: "A trail for every decision",
|
|
3143
|
+
body: "Each approval keeps its context, reviewer, and timestamp, so an audit takes an afternoon instead of a quarter.",
|
|
3038
3144
|
},
|
|
3039
3145
|
]
|
|
3040
3146
|
|
|
3041
|
-
const
|
|
3042
|
-
{
|
|
3043
|
-
|
|
3044
|
-
|
|
3147
|
+
const steps = [
|
|
3148
|
+
{
|
|
3149
|
+
number: "01",
|
|
3150
|
+
title: "Connect your spaces",
|
|
3151
|
+
body: "Bring teams, vendors, and record stores into one workspace, with roles that mirror how you already work.",
|
|
3152
|
+
},
|
|
3153
|
+
{
|
|
3154
|
+
number: "02",
|
|
3155
|
+
title: "Route every approval",
|
|
3156
|
+
body: "Requests follow the path you define, with reviewers, deadlines, and escalation built in from the start.",
|
|
3157
|
+
},
|
|
3158
|
+
{
|
|
3159
|
+
number: "03",
|
|
3160
|
+
title: "Seal the record",
|
|
3161
|
+
body: "Approved work is archived with its full context and is ready for review the moment it closes.",
|
|
3162
|
+
},
|
|
3163
|
+
]
|
|
3164
|
+
|
|
3165
|
+
const outcomes = [
|
|
3166
|
+
{ value: "3.2×", label: "faster approvals in the first quarter" },
|
|
3167
|
+
{ value: "68%", label: "fewer follow-ups on handoffs" },
|
|
3168
|
+
{ value: "1 day", label: "to prepare a quarterly audit" },
|
|
3045
3169
|
]
|
|
3046
3170
|
|
|
3047
3171
|
const footerGroups = [
|
|
@@ -3049,9 +3173,9 @@ const footerGroups = [
|
|
|
3049
3173
|
title: "Platform",
|
|
3050
3174
|
links: [
|
|
3051
3175
|
{ label: "Overview", href: "#platform" },
|
|
3052
|
-
{ label: "Signals", href: "
|
|
3053
|
-
{ label: "Approvals", href: "
|
|
3054
|
-
{ label: "Records", href: "
|
|
3176
|
+
{ label: "Signals", href: "/signals" },
|
|
3177
|
+
{ label: "Approvals", href: "/approvals" },
|
|
3178
|
+
{ label: "Records", href: "/records" },
|
|
3055
3179
|
],
|
|
3056
3180
|
},
|
|
3057
3181
|
{
|
|
@@ -3059,7 +3183,7 @@ const footerGroups = [
|
|
|
3059
3183
|
links: [
|
|
3060
3184
|
{ label: "About", href: "/about" },
|
|
3061
3185
|
{ label: "Security", href: "/security" },
|
|
3062
|
-
{ label: "Customers", href: "
|
|
3186
|
+
{ label: "Customers", href: "#customers" },
|
|
3063
3187
|
{ label: "Careers", href: "/careers" },
|
|
3064
3188
|
],
|
|
3065
3189
|
},
|
|
@@ -3074,10 +3198,10 @@ const footerGroups = [
|
|
|
3074
3198
|
},
|
|
3075
3199
|
]
|
|
3076
3200
|
|
|
3077
|
-
const
|
|
3078
|
-
{ label: "
|
|
3079
|
-
{ label: "
|
|
3080
|
-
{ label: "
|
|
3201
|
+
const legalLinks = [
|
|
3202
|
+
{ label: "Privacy", href: "/privacy" },
|
|
3203
|
+
{ label: "Terms", href: "/terms" },
|
|
3204
|
+
{ label: "Contact", href: "/contact" },
|
|
3081
3205
|
]
|
|
3082
3206
|
|
|
3083
3207
|
export function BrandLandingPage() {
|
|
@@ -3098,8 +3222,8 @@ export function BrandLandingPage() {
|
|
|
3098
3222
|
<div className="hidden lg:block">
|
|
3099
3223
|
<Row gap={1}>
|
|
3100
3224
|
{navItems.map((item) => (
|
|
3101
|
-
<Button key={item} variant="ghost" size="sm" render={<a href={
|
|
3102
|
-
{item}
|
|
3225
|
+
<Button key={item.label} variant="ghost" size="sm" render={<a href={item.href} />}>
|
|
3226
|
+
{item.label}
|
|
3103
3227
|
</Button>
|
|
3104
3228
|
))}
|
|
3105
3229
|
</Row>
|
|
@@ -3117,16 +3241,16 @@ export function BrandLandingPage() {
|
|
|
3117
3241
|
</header>
|
|
3118
3242
|
|
|
3119
3243
|
<main>
|
|
3120
|
-
<Region as="section" max="wide" className="pb-
|
|
3121
|
-
<div className="grid items-center gap-
|
|
3122
|
-
<Stack gap={
|
|
3244
|
+
<Region as="section" max="wide" className="pb-20 pt-14 sm:pt-20 lg:pb-28">
|
|
3245
|
+
<div className="grid items-center gap-12 lg:grid-cols-[1fr_1.1fr] lg:gap-10">
|
|
3246
|
+
<Stack gap={3} className="max-w-2xl">
|
|
3123
3247
|
<Badge appearance="outline" icon={<Icon name="lock" className="text-sm" />}>
|
|
3124
3248
|
Private operations, made legible
|
|
3125
3249
|
</Badge>
|
|
3126
3250
|
<TextReveal
|
|
3127
3251
|
as="h1"
|
|
3128
|
-
text="A calmer command center for high-trust
|
|
3129
|
-
className="
|
|
3252
|
+
text="A calmer command center for high-trust work"
|
|
3253
|
+
className="text-balance text-display-lg sm:text-display-2xl"
|
|
3130
3254
|
once
|
|
3131
3255
|
/>
|
|
3132
3256
|
<p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">
|
|
@@ -3134,89 +3258,96 @@ export function BrandLandingPage() {
|
|
|
3134
3258
|
surface: approvals, signals, records, and handoffs in one
|
|
3135
3259
|
understandable flow.
|
|
3136
3260
|
</p>
|
|
3137
|
-
<Row gap={2} wrap>
|
|
3261
|
+
<Row gap={2} wrap className="pt-3">
|
|
3138
3262
|
<Button size="cta" render={<a href="/demo" />}>
|
|
3139
3263
|
Book a walkthrough
|
|
3140
3264
|
</Button>
|
|
3141
3265
|
<Button size="cta" variant="outline" render={<a href="#platform" />}>
|
|
3142
|
-
Explore platform
|
|
3266
|
+
Explore the platform
|
|
3143
3267
|
</Button>
|
|
3144
3268
|
</Row>
|
|
3145
3269
|
</Stack>
|
|
3146
3270
|
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3271
|
+
{/* The product scene is a dark island: `dark` re-scopes every
|
|
3272
|
+
semantic token, so its surfaces, muted ink and status tones
|
|
3273
|
+
are the theme's own dark values in either page mode. It is a
|
|
3274
|
+
picture of the product, so it stays out of the tab order and
|
|
3275
|
+
the accessibility tree; the hero copy carries its meaning. */}
|
|
3276
|
+
<div className="dark" aria-hidden inert>
|
|
3277
|
+
<div className="overflow-hidden rounded-xl border border-border-floating bg-popover p-3 text-popover-foreground shadow-xl">
|
|
3278
|
+
{/* Nested surfaces take concentric corners: each level's radius is
|
|
3279
|
+
the shell's xl minus every inset between it and the shell edge. */}
|
|
3280
|
+
<div className="rounded-concentric-xl/3 border border-border bg-card">
|
|
3281
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
3153
3282
|
<Row gap={1}>
|
|
3154
3283
|
<StatusDot tone="warning" />
|
|
3155
3284
|
<StatusDot tone="edition" />
|
|
3156
3285
|
<StatusDot tone="success" />
|
|
3157
3286
|
</Row>
|
|
3158
|
-
<span className="text-label-sm text-
|
|
3287
|
+
<span className="text-label-sm text-muted-foreground">Live workspace</span>
|
|
3159
3288
|
</div>
|
|
3160
3289
|
<div className="grid gap-3 p-3 md:grid-cols-[0.7fr_1.3fr]">
|
|
3161
|
-
<div className="space-y-3">
|
|
3162
|
-
<div className="rounded-concentric-xl/6 border border-
|
|
3163
|
-
<p className="text-label-sm text-
|
|
3290
|
+
<div className="space-y-3 max-md:hidden">
|
|
3291
|
+
<div className="rounded-concentric-xl/6 border border-border bg-background p-4">
|
|
3292
|
+
<p className="text-label-sm text-muted-foreground">Spaces</p>
|
|
3164
3293
|
<div className="mt-4 space-y-2">
|
|
3165
|
-
{
|
|
3166
|
-
<div key={
|
|
3294
|
+
{spaces.map((space) => (
|
|
3295
|
+
<div key={space.name} className="rounded-concentric-xl/10 bg-card p-3">
|
|
3167
3296
|
<Row justify="between">
|
|
3168
|
-
<span className="text-title-sm">{
|
|
3169
|
-
<span className="text-mono-sm
|
|
3297
|
+
<span className="text-title-sm">{space.name}</span>
|
|
3298
|
+
<span className="text-mono-sm">{space.value}</span>
|
|
3170
3299
|
</Row>
|
|
3171
|
-
<p className="mt-1 text-body-xs text-
|
|
3300
|
+
<p className="mt-1 text-body-xs text-muted-foreground">{space.label}</p>
|
|
3172
3301
|
</div>
|
|
3173
3302
|
))}
|
|
3174
3303
|
</div>
|
|
3175
3304
|
</div>
|
|
3176
|
-
<div className="rounded-concentric-xl/6 border border-
|
|
3305
|
+
<div className="rounded-concentric-xl/6 border border-border bg-background p-4">
|
|
3177
3306
|
<Row justify="between">
|
|
3178
3307
|
<span className="text-title-sm">Route status</span>
|
|
3179
|
-
<Badge tone="success" size="sm">
|
|
3308
|
+
<Badge tone="success" size="sm" dot>
|
|
3309
|
+
Healthy
|
|
3310
|
+
</Badge>
|
|
3180
3311
|
</Row>
|
|
3181
3312
|
<div className="mt-4 space-y-2">
|
|
3182
|
-
{
|
|
3183
|
-
<Row key={
|
|
3313
|
+
{routes.map((route) => (
|
|
3314
|
+
<Row key={route.label} justify="between" className="rounded-full bg-card px-3 py-2">
|
|
3184
3315
|
<Row gap={1}>
|
|
3185
|
-
<StatusDot tone={
|
|
3186
|
-
<span className="text-body-sm">{
|
|
3316
|
+
<StatusDot tone={route.tone} />
|
|
3317
|
+
<span className="text-body-sm">{route.label}</span>
|
|
3187
3318
|
</Row>
|
|
3188
|
-
<span className="text-label-sm text-
|
|
3319
|
+
<span className="text-label-sm text-muted-foreground">{route.state}</span>
|
|
3189
3320
|
</Row>
|
|
3190
3321
|
))}
|
|
3191
3322
|
</div>
|
|
3192
3323
|
</div>
|
|
3193
3324
|
</div>
|
|
3194
3325
|
|
|
3195
|
-
<div className="rounded-concentric-xl/6 border border-
|
|
3196
|
-
<Row justify="between" className="border-b border-
|
|
3326
|
+
<div className="rounded-concentric-xl/6 border border-border bg-background p-4">
|
|
3327
|
+
<Row justify="between" className="border-b border-border pb-4">
|
|
3197
3328
|
<div>
|
|
3198
|
-
<p className="text-label-sm text-
|
|
3199
|
-
<
|
|
3329
|
+
<p className="text-label-sm text-muted-foreground">Signal map</p>
|
|
3330
|
+
<p className="mt-1 text-heading-4 sm:text-heading-3">Every approval path in view</p>
|
|
3200
3331
|
</div>
|
|
3201
3332
|
<Button variant="secondary" size="sm">Review</Button>
|
|
3202
3333
|
</Row>
|
|
3203
|
-
<div className="mt-
|
|
3204
|
-
<div className="grid grid-cols-9 items-end gap-
|
|
3205
|
-
{bars.map((bar
|
|
3206
|
-
<div key={
|
|
3334
|
+
<div className="mt-5 grid gap-4 sm:mt-6 sm:gap-5">
|
|
3335
|
+
<div className="grid grid-cols-9 items-end gap-1.5 rounded-concentric-xl/10 bg-card p-4 sm:gap-2">
|
|
3336
|
+
{bars.map((bar) => (
|
|
3337
|
+
<div key={bar.week} className="flex h-36 items-end sm:h-56">
|
|
3207
3338
|
<div
|
|
3208
|
-
className={`w-full rounded-full ${bar.
|
|
3339
|
+
className={`w-full rounded-full ${bar.current ? "bg-success" : "bg-secondary"}`}
|
|
3209
3340
|
style={{ height: bar.height }}
|
|
3210
3341
|
/>
|
|
3211
3342
|
</div>
|
|
3212
3343
|
))}
|
|
3213
3344
|
</div>
|
|
3214
|
-
<div className="grid
|
|
3215
|
-
{
|
|
3216
|
-
<div key={metric.label} className="rounded-concentric-xl/10 border border-
|
|
3217
|
-
<p className="text-label-sm text-
|
|
3218
|
-
<p className="mt-2 text-heading-3">{metric.value}</p>
|
|
3219
|
-
<p className="mt-1 text-body-xs text-
|
|
3345
|
+
<div className="grid grid-cols-3 gap-2">
|
|
3346
|
+
{sceneMetrics.map((metric) => (
|
|
3347
|
+
<div key={metric.label} className="rounded-concentric-xl/10 border border-border bg-card p-3">
|
|
3348
|
+
<p className="truncate text-label-sm text-muted-foreground">{metric.label}</p>
|
|
3349
|
+
<p className="mt-2 text-heading-4 sm:text-heading-3">{metric.value}</p>
|
|
3350
|
+
<p className="mt-1 text-body-xs text-muted-foreground max-sm:hidden">{metric.detail}</p>
|
|
3220
3351
|
</div>
|
|
3221
3352
|
))}
|
|
3222
3353
|
</div>
|
|
@@ -3229,54 +3360,136 @@ export function BrandLandingPage() {
|
|
|
3229
3360
|
</div>
|
|
3230
3361
|
</Region>
|
|
3231
3362
|
|
|
3232
|
-
<section id="platform" className="border-y border-border bg-muted/40 py-
|
|
3363
|
+
<section id="platform" aria-labelledby="brand-platform-title" className="border-y border-border bg-muted/40 py-20 lg:py-28">
|
|
3233
3364
|
<Region max="wide">
|
|
3234
|
-
<
|
|
3235
|
-
<Stack gap={
|
|
3236
|
-
<
|
|
3237
|
-
<h2 className="text-heading-1">
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3365
|
+
<Stack gap={6}>
|
|
3366
|
+
<Stack gap={2} className="max-w-2xl">
|
|
3367
|
+
<p className="text-overline text-muted-foreground">Why Cove</p>
|
|
3368
|
+
<h2 id="brand-platform-title" className="text-balance text-heading-1">
|
|
3369
|
+
One private workspace for the decisions that matter.
|
|
3370
|
+
</h2>
|
|
3371
|
+
<p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">
|
|
3372
|
+
Most operations work lives in threads, spreadsheets, and memory.
|
|
3373
|
+
Cove gives it a home: every request routed, every signal visible,
|
|
3374
|
+
every outcome on the record.
|
|
3242
3375
|
</p>
|
|
3243
3376
|
</Stack>
|
|
3244
|
-
<
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
<
|
|
3248
|
-
<
|
|
3249
|
-
<
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3377
|
+
<Reveal>
|
|
3378
|
+
<Grid minItemWidth="calc(var(--spacing) * 64)" gap={2}>
|
|
3379
|
+
{values.map((value) => (
|
|
3380
|
+
<Card key={value.title} render={<article />} className="h-full">
|
|
3381
|
+
<CardContent className="flex flex-col gap-2">
|
|
3382
|
+
<span className="mb-4 grid size-11 place-items-center rounded-md border border-border sable-well">
|
|
3383
|
+
<Icon name={value.icon} className="text-xl" />
|
|
3384
|
+
</span>
|
|
3385
|
+
<h3 className="text-title-lg">{value.title}</h3>
|
|
3386
|
+
<p className="text-body-sm text-muted-foreground">{value.body}</p>
|
|
3387
|
+
</CardContent>
|
|
3388
|
+
</Card>
|
|
3389
|
+
))}
|
|
3390
|
+
</Grid>
|
|
3391
|
+
</Reveal>
|
|
3392
|
+
</Stack>
|
|
3258
3393
|
</Region>
|
|
3259
3394
|
</section>
|
|
3260
3395
|
|
|
3261
|
-
<Region as="section" max="wide" className="py-
|
|
3262
|
-
<div className="grid gap-
|
|
3263
|
-
{
|
|
3264
|
-
<
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3396
|
+
<Region as="section" id="how-it-works" aria-labelledby="brand-steps-title" max="wide" className="py-20 lg:py-28">
|
|
3397
|
+
<div className="grid gap-12 lg:grid-cols-[1fr_2fr] lg:gap-16">
|
|
3398
|
+
<Stack gap={2} className="max-w-md">
|
|
3399
|
+
<p className="text-overline text-muted-foreground">How it works</p>
|
|
3400
|
+
<h2 id="brand-steps-title" className="text-balance text-heading-1">
|
|
3401
|
+
From request to sealed record.
|
|
3402
|
+
</h2>
|
|
3403
|
+
<p className="text-body-md text-muted-foreground">
|
|
3404
|
+
Teams are usually live within a week. Cove adapts to the routes
|
|
3405
|
+
you already run instead of asking you to start over.
|
|
3406
|
+
</p>
|
|
3407
|
+
<div className="pt-3">
|
|
3408
|
+
<Button variant="outline" iconEnd={<Icon name="arrow-right" />} render={<a href="/guides/rollout" />}>
|
|
3409
|
+
Read the rollout guide
|
|
3410
|
+
</Button>
|
|
3411
|
+
</div>
|
|
3412
|
+
</Stack>
|
|
3413
|
+
<ol className="grid gap-10 sm:grid-cols-3 sm:gap-6">
|
|
3414
|
+
{steps.map((step) => (
|
|
3415
|
+
<li key={step.number} className="flex flex-col gap-2 border-t border-border-strong pt-6">
|
|
3416
|
+
<span className="text-mono-sm text-muted-foreground">{step.number}</span>
|
|
3417
|
+
<h3 className="mt-3 text-title-lg sm:mt-6">{step.title}</h3>
|
|
3418
|
+
<p className="text-body-sm text-muted-foreground">{step.body}</p>
|
|
3419
|
+
</li>
|
|
3420
|
+
))}
|
|
3421
|
+
</ol>
|
|
3422
|
+
</div>
|
|
3423
|
+
</Region>
|
|
3424
|
+
|
|
3425
|
+
<Region as="section" id="customers" aria-label="Customer story" max="wide" className="pb-20 lg:pb-28">
|
|
3426
|
+
<Card render={<figure />}>
|
|
3427
|
+
<div className="grid lg:grid-cols-[1.5fr_1fr]">
|
|
3428
|
+
<CardContent className="flex flex-col justify-between gap-10 sm:p-10">
|
|
3429
|
+
<Stack gap={3}>
|
|
3430
|
+
<p className="text-overline text-muted-foreground">Customer story</p>
|
|
3431
|
+
<blockquote className="text-balance text-heading-3 sm:text-heading-2">
|
|
3432
|
+
“We used to chase approvals across four tools. Now every request
|
|
3433
|
+
has an owner, a deadline, and a record, and our auditors stopped
|
|
3434
|
+
asking for screenshots.”
|
|
3435
|
+
</blockquote>
|
|
3436
|
+
</Stack>
|
|
3437
|
+
<figcaption className="flex items-center gap-3">
|
|
3438
|
+
<Avatar size="lg" fallback="MO" />
|
|
3439
|
+
<div>
|
|
3440
|
+
<p className="text-title-sm">Maya Okafor</p>
|
|
3441
|
+
<p className="text-body-sm text-muted-foreground">Head of operations, Fielddesk</p>
|
|
3442
|
+
</div>
|
|
3443
|
+
</figcaption>
|
|
3444
|
+
</CardContent>
|
|
3445
|
+
<div className="border-border max-lg:border-t lg:border-s">
|
|
3446
|
+
<CardContent className="flex h-full flex-col justify-center gap-6 sm:p-10">
|
|
3447
|
+
{outcomes.map((outcome, index) => (
|
|
3448
|
+
<Stack key={outcome.label} gap={3}>
|
|
3449
|
+
{index > 0 && <Separator />}
|
|
3450
|
+
<div>
|
|
3451
|
+
<p className="text-heading-1 sm:text-display-lg">{outcome.value}</p>
|
|
3452
|
+
<p className="mt-1 text-body-sm text-muted-foreground">{outcome.label}</p>
|
|
3453
|
+
</div>
|
|
3454
|
+
</Stack>
|
|
3455
|
+
))}
|
|
3269
3456
|
</CardContent>
|
|
3270
|
-
</
|
|
3271
|
-
|
|
3457
|
+
</div>
|
|
3458
|
+
</div>
|
|
3459
|
+
</Card>
|
|
3460
|
+
</Region>
|
|
3461
|
+
|
|
3462
|
+
<Region as="section" aria-labelledby="brand-cta-title" max="wide" className="pb-20 lg:pb-28">
|
|
3463
|
+
<div className="dark">
|
|
3464
|
+
<div className="rounded-xl border border-border bg-card px-6 py-12 text-card-foreground sm:px-12 sm:py-16">
|
|
3465
|
+
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
|
3466
|
+
<Stack gap={3} className="max-w-2xl">
|
|
3467
|
+
<h2 id="brand-cta-title" className="text-balance text-heading-1">
|
|
3468
|
+
See your own approval paths in Cove.
|
|
3469
|
+
</h2>
|
|
3470
|
+
<p className="text-body-lg text-muted-foreground">
|
|
3471
|
+
A 30-minute walkthrough with your routes, your roles, and your
|
|
3472
|
+
records. No slides.
|
|
3473
|
+
</p>
|
|
3474
|
+
</Stack>
|
|
3475
|
+
<Row gap={2} wrap>
|
|
3476
|
+
<Button size="cta" render={<a href="/demo" />}>
|
|
3477
|
+
Book a walkthrough
|
|
3478
|
+
</Button>
|
|
3479
|
+
<Button size="cta" variant="ghost" render={<a href="/pricing" />}>
|
|
3480
|
+
View pricing
|
|
3481
|
+
</Button>
|
|
3482
|
+
</Row>
|
|
3483
|
+
</div>
|
|
3484
|
+
</div>
|
|
3272
3485
|
</div>
|
|
3273
3486
|
</Region>
|
|
3274
3487
|
</main>
|
|
3275
3488
|
|
|
3276
3489
|
<footer className="border-t border-border bg-muted/35">
|
|
3277
|
-
<Region max="wide" className="py-
|
|
3278
|
-
<div className="grid gap-
|
|
3279
|
-
<Stack gap={
|
|
3490
|
+
<Region max="wide" className="py-12 sm:py-16">
|
|
3491
|
+
<div className="grid gap-10 lg:grid-cols-[1fr_1.35fr_1fr] lg:gap-8">
|
|
3492
|
+
<Stack gap={4} className="max-w-sm">
|
|
3280
3493
|
<a href="/" className="flex w-fit items-center gap-2 rounded-full motion-interactive motion-press">
|
|
3281
3494
|
<span className="grid size-9 place-items-center rounded-full bg-primary text-primary-foreground">
|
|
3282
3495
|
<Icon name="sparkles" className="text-sm" />
|
|
@@ -3287,20 +3500,15 @@ export function BrandLandingPage() {
|
|
|
3287
3500
|
A calm operating surface for teams that need trust, speed, and
|
|
3288
3501
|
context without the noise.
|
|
3289
3502
|
</p>
|
|
3290
|
-
<
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
</Badge>
|
|
3294
|
-
<Badge tone="success" size="sm" icon={<Icon name="circle-check" />}>
|
|
3295
|
-
Live
|
|
3296
|
-
</Badge>
|
|
3297
|
-
</Row>
|
|
3503
|
+
<Badge appearance="plain" tone="success" dot>
|
|
3504
|
+
All systems normal
|
|
3505
|
+
</Badge>
|
|
3298
3506
|
</Stack>
|
|
3299
3507
|
|
|
3300
|
-
<nav aria-label="Footer" className="grid
|
|
3508
|
+
<nav aria-label="Footer" className="grid grid-cols-3 gap-6">
|
|
3301
3509
|
{footerGroups.map((group) => (
|
|
3302
3510
|
<div key={group.title}>
|
|
3303
|
-
<h2 className="text-label-md
|
|
3511
|
+
<h2 className="text-label-md">{group.title}</h2>
|
|
3304
3512
|
<ul className="mt-3 space-y-1">
|
|
3305
3513
|
{group.links.map((link) => (
|
|
3306
3514
|
<li key={link.label}>
|
|
@@ -3317,22 +3525,17 @@ export function BrandLandingPage() {
|
|
|
3317
3525
|
))}
|
|
3318
3526
|
</nav>
|
|
3319
3527
|
|
|
3320
|
-
<Card>
|
|
3528
|
+
<Card className="h-fit">
|
|
3321
3529
|
<CardHeader>
|
|
3322
3530
|
{/* CardTitle carries the type; the h2 inside it keeps the outline. */}
|
|
3323
3531
|
<CardTitle>
|
|
3324
|
-
<h2>
|
|
3532
|
+
<h2>The Cove digest</h2>
|
|
3325
3533
|
</CardTitle>
|
|
3326
3534
|
<CardDescription>
|
|
3327
|
-
One
|
|
3535
|
+
One note a month on trusted workflows and product releases.
|
|
3328
3536
|
</CardDescription>
|
|
3329
|
-
<CardAction>
|
|
3330
|
-
<span className="grid size-9 place-items-center rounded-full bg-highlight text-highlight-foreground">
|
|
3331
|
-
<Icon name="sparkles" className="text-sm" />
|
|
3332
|
-
</span>
|
|
3333
|
-
</CardAction>
|
|
3334
3537
|
</CardHeader>
|
|
3335
|
-
<CardContent
|
|
3538
|
+
<CardContent>
|
|
3336
3539
|
<form action="/newsletter" className="grid gap-2 sm:grid-cols-[1fr_auto]">
|
|
3337
3540
|
<label htmlFor="brand-footer-email" className="sr-only">
|
|
3338
3541
|
Work email
|
|
@@ -3345,42 +3548,25 @@ export function BrandLandingPage() {
|
|
|
3345
3548
|
placeholder="you@company.com"
|
|
3346
3549
|
/>
|
|
3347
3550
|
<Button type="submit" className="w-full sm:w-auto">
|
|
3348
|
-
|
|
3551
|
+
Subscribe
|
|
3349
3552
|
</Button>
|
|
3350
3553
|
</form>
|
|
3351
|
-
<div className="grid grid-cols-3 gap-2">
|
|
3352
|
-
{footerSignals.map((signal) => (
|
|
3353
|
-
<div key={signal.label} className="rounded-md bg-muted p-3">
|
|
3354
|
-
<p className="text-mono-sm">{signal.value}</p>
|
|
3355
|
-
<p className="mt-1 text-caption text-muted-foreground">{signal.label}</p>
|
|
3356
|
-
</div>
|
|
3357
|
-
))}
|
|
3358
|
-
</div>
|
|
3359
3554
|
</CardContent>
|
|
3360
3555
|
</Card>
|
|
3361
3556
|
</div>
|
|
3362
3557
|
|
|
3363
|
-
<div className="mt-
|
|
3558
|
+
<div className="mt-12 flex flex-col gap-3 border-t border-border pt-6 text-caption text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
|
3364
3559
|
<p>© 2026 Cove. All rights reserved.</p>
|
|
3365
|
-
<Row gap={
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
>
|
|
3376
|
-
Terms
|
|
3377
|
-
</a>
|
|
3378
|
-
<a
|
|
3379
|
-
href="/contact"
|
|
3380
|
-
className="flex min-h-9 min-w-9 items-center motion-interactive hover:text-foreground"
|
|
3381
|
-
>
|
|
3382
|
-
Contact
|
|
3383
|
-
</a>
|
|
3560
|
+
<Row gap={4} wrap>
|
|
3561
|
+
{legalLinks.map((link) => (
|
|
3562
|
+
<a
|
|
3563
|
+
key={link.label}
|
|
3564
|
+
href={link.href}
|
|
3565
|
+
className="flex min-h-9 min-w-9 items-center motion-interactive hover:text-foreground"
|
|
3566
|
+
>
|
|
3567
|
+
{link.label}
|
|
3568
|
+
</a>
|
|
3569
|
+
))}
|
|
3384
3570
|
</Row>
|
|
3385
3571
|
</div>
|
|
3386
3572
|
</Region>
|
|
@@ -3406,7 +3592,11 @@ A full-height conversation surface: titled header, scrolling transcript, and a c
|
|
|
3406
3592
|
- Region cap is `feed` (640px) — a conversation is a reading column, and the Bubble measure cap is tuned to the same assumption.
|
|
3407
3593
|
- The composer sits outside the scroll region, in a bordered CardFooter, so it stays put while the transcript moves.
|
|
3408
3594
|
- Grouping is derived from the data (`turns[i - 1].mine === turn.mine`) rather than hand-set, and the tail is dropped on continuations — that pairing is what makes a run read as one block.
|
|
3409
|
-
-
|
|
3595
|
+
- What the assistant did before answering — a doc it read, a tool it ran — is a `Marker variant="note"` in that message's `header`, so it reads as part of the turn rather than as an event between turns.
|
|
3596
|
+
- The assistant's long-form answer uses `variant="plain"` and continues the same grouped run. A tinted box around a code block reads as a quote rather than as a turn. A plain Bubble sizes to its content with no measure cap, so it takes `max-w-full` here: bounded by the column, a wide code line scrolls inside the CodeBlock instead of pushing the transcript sideways on a phone.
|
|
3597
|
+
- A settled answer carries its actions (copy, regenerate) in the Message `footer` as a `Toolbar` of icon `ToolbarButton`s with tooltips — one tab stop, arrow keys between them. The answer still streaming has none: there is nothing to copy or regenerate until it settles, and Stop lives in the composer.
|
|
3598
|
+
- One turn, one Message: the reader's question and the file it refers to stack inside a single end-aligned Message rather than being sent as two.
|
|
3599
|
+
- Assistant turns share one Avatar with a glyph fallback and `alt="Assistant"` — initials like "AI" read as a person, and a fallback-only avatar is otherwise unnamed.
|
|
3410
3600
|
- The answer in flight is a `StreamingText` in a `plain` Bubble, and ONE `pending` flag drives both it and the composer's `busy`. They are the same wait reported at the two ends of the panel; letting them drift gives you a composer still saying 'sending' under an answer that already finished.
|
|
3411
3601
|
- A spinner `Marker` (or `ThinkingOrb`) belongs BEFORE the first token and is replaced once tokens land, not kept alongside. Two indicators for one wait read as two things happening — which is also why `StreamingText` takes `caret={false}` when something adjacent already reports it.
|
|
3412
3602
|
- MessageScroller needs no configuring for this: it re-follows on "a message grew" (ResizeObserver) as well as "a message arrived", so the newest token stays pinned while a reader who has scrolled up is left alone.
|
|
@@ -3416,12 +3606,12 @@ A full-height conversation surface: titled header, scrolling transcript, and a c
|
|
|
3416
3606
|
|
|
3417
3607
|
- The panel header's title is the page's <h1> (an h1 inside CardTitle, which carries the type); the transcript is a scroll region with native overflow, so keyboard scrolling works unchanged.
|
|
3418
3608
|
- The answer in flight carries `aria-busy` rather than sitting in a live region. That is deliberate: a live region around streamed text announces every token, which is unusable. Announce once when the turn settles, from whatever owns the turn — the incomplete state is still exposed, just not narrated.
|
|
3419
|
-
- The composer is a real <form> whose Enter handling skips IME composition, and every icon-only control in the panel carries an accessible label.
|
|
3609
|
+
- The composer is a real <form> whose Enter handling skips IME composition, and every icon-only control in the panel carries an accessible label — the answer actions also carry a tooltip, and the stop control is named for what it stops (`stopLabel="Stop answering"`).
|
|
3420
3610
|
|
|
3421
3611
|
## Source
|
|
3422
3612
|
|
|
3423
3613
|
```tsx
|
|
3424
|
-
import { Attachment, Avatar, Bubble,
|
|
3614
|
+
import { Attachment, Avatar, Bubble, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, ChatComposer, CodeBlock, Icon, Marker, MenuRadioGroup, MenuRadioItem, Message, MessageScroller, Page, SettingsMenu, SettingsRow, SimpleTooltip, StreamingText, Toolbar, ToolbarButton } from "@sableui/react"
|
|
3425
3615
|
|
|
3426
3616
|
/**
|
|
3427
3617
|
* The request is in flight. In an app this is your send state — from `useChat`,
|
|
@@ -3434,9 +3624,14 @@ const pending = true
|
|
|
3434
3624
|
|
|
3435
3625
|
/** The answer so far. Append tokens to this; `StreamingText` assumes it only grows. */
|
|
3436
3626
|
const streamed =
|
|
3437
|
-
"
|
|
3627
|
+
"Yes, appends are already safe: your layout effect measures before paint. The jump is on the prepend path, which reads scrollHeight after the new rows"
|
|
3438
3628
|
|
|
3439
|
-
|
|
3629
|
+
/**
|
|
3630
|
+
* The opening exchange. `step` is what the assistant did before answering — a
|
|
3631
|
+
* doc it read, a tool it ran — and it rides in that message's header, so it
|
|
3632
|
+
* reads as part of the turn rather than as an event between turns.
|
|
3633
|
+
*/
|
|
3634
|
+
const turns: { id: string; mine: boolean; step?: string; body: string }[] = [
|
|
3440
3635
|
{
|
|
3441
3636
|
id: "t_01",
|
|
3442
3637
|
mine: true,
|
|
@@ -3445,6 +3640,7 @@ const turns = [
|
|
|
3445
3640
|
{
|
|
3446
3641
|
id: "t_02",
|
|
3447
3642
|
mine: false,
|
|
3643
|
+
step: "Read message-scroller.md",
|
|
3448
3644
|
body: "Almost always the anchor. If you preserve the scroll position by measuring the container, prepending history moves everything under the reader.",
|
|
3449
3645
|
},
|
|
3450
3646
|
{
|
|
@@ -3455,6 +3651,19 @@ const turns = [
|
|
|
3455
3651
|
]
|
|
3456
3652
|
|
|
3457
3653
|
export function ChatPanel() {
|
|
3654
|
+
// One identity for every assistant turn. A glyph rather than initials: "AI"
|
|
3655
|
+
// in a circle reads as a person called A. I. The glyph is sized to the
|
|
3656
|
+
// circle (the fallback's own size is tuned for two letters, and a lone icon
|
|
3657
|
+
// at that size reads as a speck); `alt` names it for a screen reader, which
|
|
3658
|
+
// otherwise gets nothing from a fallback-only avatar.
|
|
3659
|
+
const assistant = (
|
|
3660
|
+
<Avatar
|
|
3661
|
+
size="sm"
|
|
3662
|
+
alt="Assistant"
|
|
3663
|
+
fallback={<Icon name="sparkles" className="text-base" />}
|
|
3664
|
+
/>
|
|
3665
|
+
)
|
|
3666
|
+
|
|
3458
3667
|
return (
|
|
3459
3668
|
<Page as="main" max="feed" gap={0} className="py-6">
|
|
3460
3669
|
{/* h-dvh minus the page's own padding (py-6 = 12 spacing units, in
|
|
@@ -3473,8 +3682,12 @@ export function ChatPanel() {
|
|
|
3473
3682
|
</CardTitle>
|
|
3474
3683
|
<CardDescription>Assistant · answers from your team's docs</CardDescription>
|
|
3475
3684
|
<CardAction>
|
|
3476
|
-
|
|
3477
|
-
|
|
3685
|
+
{/* Labelled, not a bare plus: the composer already has a plus
|
|
3686
|
+
(attach), and two identical glyphs a panel apart that do
|
|
3687
|
+
different things is a guessing game. */}
|
|
3688
|
+
<Button variant="ghost" size="sm">
|
|
3689
|
+
<Icon name="edit" />
|
|
3690
|
+
New chat
|
|
3478
3691
|
</Button>
|
|
3479
3692
|
</CardAction>
|
|
3480
3693
|
</CardHeader>
|
|
@@ -3484,32 +3697,67 @@ export function ChatPanel() {
|
|
|
3484
3697
|
<MessageScroller className="min-h-0 flex-1 p-(--card-p)">
|
|
3485
3698
|
<Marker variant="separator">Today</Marker>
|
|
3486
3699
|
|
|
3487
|
-
{turns.map((turn, i) =>
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3700
|
+
{turns.map((turn, i) => {
|
|
3701
|
+
// Derived, not hand-set: a turn continues a run when the one
|
|
3702
|
+
// before it has the same author. A continuation drops its
|
|
3703
|
+
// avatar (Message keeps the column) AND its tail (Bubble keeps
|
|
3704
|
+
// the ear's space) — together, that makes the run one block.
|
|
3705
|
+
const continues = i > 0 && turns[i - 1].mine === turn.mine
|
|
3706
|
+
return (
|
|
3707
|
+
<Message
|
|
3708
|
+
key={turn.id}
|
|
3709
|
+
messageId={turn.id}
|
|
3497
3710
|
align={turn.mine ? "end" : "start"}
|
|
3498
|
-
|
|
3711
|
+
grouped={continues}
|
|
3712
|
+
avatar={turn.mine ? undefined : assistant}
|
|
3713
|
+
header={
|
|
3714
|
+
turn.step ? (
|
|
3715
|
+
<Marker variant="note" icon="book" className="justify-start">
|
|
3716
|
+
{turn.step}
|
|
3717
|
+
</Marker>
|
|
3718
|
+
) : undefined
|
|
3719
|
+
}
|
|
3499
3720
|
>
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3721
|
+
<Bubble
|
|
3722
|
+
variant={turn.mine ? "primary" : "muted"}
|
|
3723
|
+
align={turn.mine ? "end" : "start"}
|
|
3724
|
+
tail={!continues}
|
|
3725
|
+
>
|
|
3726
|
+
{turn.body}
|
|
3727
|
+
</Bubble>
|
|
3728
|
+
</Message>
|
|
3729
|
+
)
|
|
3730
|
+
})}
|
|
3731
|
+
|
|
3732
|
+
{/* The same run, continued: long-form output with no bubble,
|
|
3733
|
+
because a tinted box around a code block reads as a quote
|
|
3734
|
+
rather than as a turn. Its footer carries what the reader does
|
|
3735
|
+
WITH a settled answer, as a Toolbar: one tab stop, arrow keys
|
|
3736
|
+
between the actions. */}
|
|
3737
|
+
<Message
|
|
3738
|
+
messageId="t_04"
|
|
3739
|
+
grouped
|
|
3740
|
+
avatar={assistant}
|
|
3741
|
+
footer={
|
|
3742
|
+
<Toolbar aria-label="Answer actions">
|
|
3743
|
+
<SimpleTooltip content="Copy answer">
|
|
3744
|
+
<ToolbarButton size="icon-sm" aria-label="Copy answer">
|
|
3745
|
+
<Icon name="copy" />
|
|
3746
|
+
</ToolbarButton>
|
|
3747
|
+
</SimpleTooltip>
|
|
3748
|
+
<SimpleTooltip content="Regenerate">
|
|
3749
|
+
<ToolbarButton size="icon-sm" aria-label="Regenerate answer">
|
|
3750
|
+
<Icon name="refresh" />
|
|
3751
|
+
</ToolbarButton>
|
|
3752
|
+
</SimpleTooltip>
|
|
3753
|
+
</Toolbar>
|
|
3754
|
+
}
|
|
3755
|
+
>
|
|
3756
|
+
{/* max-w-full: a plain Bubble sizes to its content with no
|
|
3757
|
+
measure cap, and a code line is wider than a phone. Bounded
|
|
3758
|
+
by the column, the CodeBlock scrolls inside itself instead of
|
|
3759
|
+
pushing the whole transcript sideways. */}
|
|
3760
|
+
<Bubble variant="plain" className="max-w-full">
|
|
3513
3761
|
<p className="text-body-md text-foreground">
|
|
3514
3762
|
Here is the shape of it:
|
|
3515
3763
|
</p>
|
|
@@ -3520,28 +3768,46 @@ export function ChatPanel() {
|
|
|
3520
3768
|
filename="preserve.ts"
|
|
3521
3769
|
/>
|
|
3522
3770
|
</Bubble>
|
|
3523
|
-
<BubbleReactions>
|
|
3524
|
-
<BubbleReaction emoji="🎯" count={2} active />
|
|
3525
|
-
</BubbleReactions>
|
|
3526
3771
|
</Message>
|
|
3527
3772
|
|
|
3773
|
+
{/* The reader's follow-up is one turn: the question and the file
|
|
3774
|
+
it refers to stack in a single Message, so they share a side, an
|
|
3775
|
+
edge and a timestamp rather than reading as two sends. */}
|
|
3528
3776
|
<Message messageId="t_05" align="end">
|
|
3529
|
-
<
|
|
3777
|
+
<Bubble variant="primary" align="end">
|
|
3778
|
+
Here's ours. Does the same fix cover new messages arriving?
|
|
3779
|
+
</Bubble>
|
|
3780
|
+
<Attachment
|
|
3781
|
+
name="scroller.tsx"
|
|
3782
|
+
meta="4.2 KB · TSX"
|
|
3783
|
+
onOpen={() => undefined}
|
|
3784
|
+
/>
|
|
3530
3785
|
</Message>
|
|
3531
3786
|
|
|
3532
3787
|
{/* The answer arriving. A `Marker` with a spinner — or ThinkingOrb —
|
|
3533
3788
|
is what sits here BEFORE the first token; once tokens land it is
|
|
3534
3789
|
replaced rather than kept, because two indicators for one wait
|
|
3535
|
-
read as two things happening.
|
|
3790
|
+
read as two things happening. What stays is the step it took,
|
|
3791
|
+
as a finished note in the header.
|
|
3536
3792
|
|
|
3537
|
-
No bubble, same as the long-form answer above: a tinted box
|
|
3538
|
-
a growing paragraph makes the reflow look like the box
|
|
3793
|
+
No bubble, same as the long-form answer above: a tinted box
|
|
3794
|
+
around a growing paragraph makes the reflow look like the box
|
|
3795
|
+
breathing. And no answer actions yet — there is nothing to copy
|
|
3796
|
+
or regenerate until it settles; Stop lives in the composer.
|
|
3539
3797
|
|
|
3540
3798
|
MessageScroller needs no help with this — it already re-follows on
|
|
3541
3799
|
"a message grew" (ResizeObserver), not just on "a message arrived",
|
|
3542
3800
|
so the newest token stays pinned while the reader can still scroll
|
|
3543
3801
|
up without being yanked back. */}
|
|
3544
|
-
<Message
|
|
3802
|
+
<Message
|
|
3803
|
+
messageId="t_06"
|
|
3804
|
+
avatar={assistant}
|
|
3805
|
+
header={
|
|
3806
|
+
<Marker variant="note" icon="file" className="justify-start">
|
|
3807
|
+
Read scroller.tsx
|
|
3808
|
+
</Marker>
|
|
3809
|
+
}
|
|
3810
|
+
>
|
|
3545
3811
|
<Bubble variant="plain">
|
|
3546
3812
|
<StreamingText text={streamed} streaming={pending} />
|
|
3547
3813
|
</Bubble>
|
|
@@ -3557,6 +3823,7 @@ export function ChatPanel() {
|
|
|
3557
3823
|
// second request on top of the one still running.
|
|
3558
3824
|
busy={pending}
|
|
3559
3825
|
onStop={() => undefined}
|
|
3826
|
+
stopLabel="Stop answering"
|
|
3560
3827
|
onAttach={() => undefined}
|
|
3561
3828
|
footer="Enter to send · Shift+Enter for a new line"
|
|
3562
3829
|
// Model and effort sit in `actions`, at the send end of the row:
|
|
@@ -3607,59 +3874,102 @@ export function ChatPanel() {
|
|
|
3607
3874
|
|
|
3608
3875
|
# DashboardPage
|
|
3609
3876
|
|
|
3610
|
-
App-shell dashboard:
|
|
3877
|
+
App-shell creator dashboard: a branded Sidebar with an account footer (AppHeader + BottomNav below lg), a KPI row, an edge-to-edge editions table with sell-through Meters, and supporting payout, collector, release and activity cards — the default shape for an operator or analytics home route.
|
|
3611
3878
|
|
|
3612
|
-
**Intent:** Reach for this when a route's job is monitoring or managing many records (
|
|
3879
|
+
**Intent:** Reach for this when a route's job is monitoring or managing many records (sales, orders, editions, posts) with a few headline numbers. Not for reading-focused routes (use FeedPage) or single-record routes (use ListDetailPage's detail side).
|
|
3613
3880
|
|
|
3614
3881
|
## Notes
|
|
3615
3882
|
|
|
3616
3883
|
- Region cap is `wide` (1280px): dashboards hold tables and multi-column grids, never a reading column.
|
|
3617
|
-
-
|
|
3618
|
-
- Stat cards
|
|
3619
|
-
-
|
|
3620
|
-
- The
|
|
3621
|
-
-
|
|
3884
|
+
- Hierarchy runs headline → records → context: KPI row, then the primary table beside its two closest companions (payout, top collectors), then the lower-priority lists (upcoming, activity). Every row of the grid is filled so the route ends on content, not empty canvas.
|
|
3885
|
+
- Stat cards: CardHeader carries the label (CardDescription) and the value; KPI values are `text-mono-xl` — the metric mono tier, tabular figures — never a heading tier. The trend Badge sits with its comparison note in CardContent, so the value keeps the full card width when the row folds to 2×2 on a phone.
|
|
3886
|
+
- Stat cards use an explicit cols map (2 → 4) — a 4-stat row must fold to 2×2, never strand an orphan the way auto-fit can.
|
|
3887
|
+
- The table runs flush to the card edges with `<CardContent flush>`, which hands the card's padding to the table (`--table-edge-px`), so the first and last columns line up with the CardHeader at any card density — never hand-set pl-6/pr-6. Numeric columns align end in mono; Table scrolls horizontally inside the card on narrow screens.
|
|
3888
|
+
- Sold / supply is a `Meter` (how full a known range is), not `Progress` (task progress). Status is `Badge appearance="plain" dot`, with the app's own states mapped to tones once at the top of the file.
|
|
3889
|
+
- Lists are Entity rows in CardList, inset to the card padding and divided by hairlines — never CardContent with hand-tuned px. A card that shares a grid row with a taller one becomes `flex flex-col` and pins its CardFooter with `mt-auto`.
|
|
3890
|
+
- Brand and account live in the Sidebar: SidebarHeader holds the mark + wordmark, SidebarFooter holds the signed-in Entity with a DotsMenu. The footer rebinds `--foreground` to `--sidebar-foreground`, because Entity's title and the ghost trigger ink with page tokens and a theme may paint the rail dark on a light page; it keeps to a title line, since there is no sidebar-muted ink for a subtitle.
|
|
3622
3891
|
- The page has exactly one heading-1 (PageHeader); card titles are Title tier, not headings.
|
|
3623
3892
|
- Chrome (Sidebar) is outside the content grid; the Page region caps only content.
|
|
3624
|
-
- Sidebar hides below lg, so the shell's header carries an AppHeader and its footer a BottomNav with the same destinations — never strand mobile users without navigation.
|
|
3893
|
+
- Sidebar hides below lg, so the shell's header carries an AppHeader (brand + account) and its footer a BottomNav with the same destinations — never strand mobile users without navigation.
|
|
3625
3894
|
|
|
3626
3895
|
## Accessibility
|
|
3627
3896
|
|
|
3628
3897
|
- AppShell provides the skip link and <main id="main-content"> landmark.
|
|
3629
3898
|
- The active SidebarItem and BottomNavItem carry aria-current via `active`.
|
|
3630
3899
|
- DataTable headers are real <th>; sortable columns are announced.
|
|
3900
|
+
- Each sell-through Meter has an aria-label naming the edition and aria-valuetext reading "184 of 250 sold".
|
|
3901
|
+
- Avatars beside a visible name use an empty alt so the name is not read twice.
|
|
3631
3902
|
|
|
3632
3903
|
## Source
|
|
3633
3904
|
|
|
3634
3905
|
```tsx
|
|
3635
|
-
import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarHeader, SidebarItem, SidebarNav } from "@sableui/react"
|
|
3906
|
+
import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, DotsMenu, Entity, Grid, Icon, MenuItem, MenuSeparator, Meter, NotificationBadge, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, Stack } from "@sableui/react"
|
|
3907
|
+
|
|
3908
|
+
// Edition status → Badge tone. Sable has no status vocabulary of its own;
|
|
3909
|
+
// an app maps ITS states onto tones once, here, and every badge follows.
|
|
3910
|
+
const STATUS_TONE = { live: "success", scheduled: "info", "sold-out": "edition", closed: "neutral" } as const
|
|
3911
|
+
const STATUS_LABEL = { live: "Live", scheduled: "Scheduled", "sold-out": "Sold out", closed: "Closed" } as const
|
|
3912
|
+
type EditionStatus = keyof typeof STATUS_TONE
|
|
3636
3913
|
|
|
3637
3914
|
const MIN = 60 * 1000
|
|
3638
3915
|
const HOUR = 60 * MIN
|
|
3916
|
+
const DAY = 24 * HOUR
|
|
3917
|
+
|
|
3918
|
+
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
|
|
3919
|
+
const usdCents = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
|
3920
|
+
const shortDate = (offset: number) =>
|
|
3921
|
+
new Date(Date.now() + offset).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
3639
3922
|
|
|
3640
3923
|
const stats = [
|
|
3641
|
-
{ label: "Revenue", value: "$
|
|
3642
|
-
{ label: "Collects", value: "1,284", delta: "+3.1%", up: true, note: "182
|
|
3643
|
-
{ label: "
|
|
3644
|
-
{ label: "Sell-through", value: "64%", delta: "−1.8%", up: false, note: "
|
|
3924
|
+
{ label: "Revenue", value: "$18,240", delta: "+8.2%", up: true, note: "vs. $16,857" },
|
|
3925
|
+
{ label: "Collects", value: "1,284", delta: "+3.1%", up: true, note: "182 first-time" },
|
|
3926
|
+
{ label: "Collectors", value: "312", delta: "+12.4%", up: true, note: "Best month yet" },
|
|
3927
|
+
{ label: "Sell-through", value: "64%", delta: "−1.8%", up: false, note: "6 open editions" },
|
|
3645
3928
|
]
|
|
3646
3929
|
|
|
3647
|
-
const
|
|
3648
|
-
{ id: "
|
|
3649
|
-
{ id: "
|
|
3650
|
-
{ id: "
|
|
3651
|
-
{ id: "
|
|
3652
|
-
{ id: "
|
|
3653
|
-
{ id: "
|
|
3654
|
-
{ id: "
|
|
3930
|
+
const editions = [
|
|
3931
|
+
{ id: "ed_01", title: "Gold Rush", status: "live" as const, sold: 184, supply: 250, price: 48, released: -6 * DAY },
|
|
3932
|
+
{ id: "ed_02", title: "Quiet Harbor", status: "sold-out" as const, sold: 100, supply: 100, price: 120, released: -19 * DAY },
|
|
3933
|
+
{ id: "ed_03", title: "Dusk, no. 4", status: "live" as const, sold: 61, supply: 150, price: 64, released: -2 * DAY },
|
|
3934
|
+
{ id: "ed_04", title: "Tidewater", status: "live" as const, sold: 212, supply: 300, price: 36, released: -27 * DAY },
|
|
3935
|
+
{ id: "ed_05", title: "Salt Study", status: "closed" as const, sold: 38, supply: 75, price: 90, released: -44 * DAY },
|
|
3936
|
+
{ id: "ed_06", title: "Paper Moon", status: "sold-out" as const, sold: 50, supply: 50, price: 220, released: -58 * DAY },
|
|
3937
|
+
{ id: "ed_07", title: "Night Signal", status: "scheduled" as const, sold: 0, supply: 200, price: 56, released: 3 * DAY },
|
|
3938
|
+
{ id: "ed_08", title: "Low Tide, Blue", status: "closed" as const, sold: 72, supply: 120, price: 42, released: -71 * DAY },
|
|
3939
|
+
{ id: "ed_09", title: "Ember Field", status: "sold-out" as const, sold: 80, supply: 80, price: 140, released: -86 * DAY },
|
|
3940
|
+
]
|
|
3941
|
+
const editionCount = 24
|
|
3942
|
+
|
|
3943
|
+
const payout = {
|
|
3944
|
+
amount: 8214.6,
|
|
3945
|
+
date: 5 * DAY,
|
|
3946
|
+
lines: [
|
|
3947
|
+
{ label: "Sales", value: 9127.33 },
|
|
3948
|
+
{ label: "Platform fee (10%)", value: -912.73 },
|
|
3949
|
+
{ label: "Deposits to", value: "Checking ••4821" },
|
|
3950
|
+
],
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
const collectors = [
|
|
3954
|
+
{ id: "co_01", name: "Nina Simone", handle: "@nina", initials: "NS", pieces: 14, spent: 1460 },
|
|
3955
|
+
{ id: "co_02", name: "Grace Hopper", handle: "@grace", initials: "GH", pieces: 11, spent: 1128 },
|
|
3956
|
+
{ id: "co_03", name: "Frida Kahlo", handle: "@frida", initials: "FK", pieces: 9, spent: 942 },
|
|
3957
|
+
{ id: "co_04", name: "Mary Shelley", handle: "@mary", initials: "MS", pieces: 6, spent: 588 },
|
|
3958
|
+
]
|
|
3959
|
+
|
|
3960
|
+
const upcoming = [
|
|
3961
|
+
{ id: "up_01", title: "Night Signal", detail: "Edition of 200 · $56", at: 3 * DAY },
|
|
3962
|
+
{ id: "up_02", title: "Tidewater, second printing", detail: "Edition of 100 · $40", at: 12 * DAY },
|
|
3963
|
+
{ id: "up_03", title: "Dusk, no. 5", detail: "Edition of 150 · $64", at: 26 * DAY },
|
|
3964
|
+
{ id: "up_04", title: "Quiet Harbor, artist proofs", detail: "Edition of 10 · $480", at: 34 * DAY },
|
|
3655
3965
|
]
|
|
3656
3966
|
|
|
3657
3967
|
const activity = [
|
|
3658
|
-
{ id: "ac_01", who: "Nina Simone", initials: "NS", what: "collected Gold Rush
|
|
3659
|
-
{ id: "ac_02", who: "Grace Hopper", initials: "GH", what: "
|
|
3660
|
-
{ id: "ac_03", who: "
|
|
3661
|
-
{ id: "ac_04", who: "Mary Shelley", initials: "MS", what: "
|
|
3662
|
-
{ id: "ac_05", who: "
|
|
3968
|
+
{ id: "ac_01", who: "Nina Simone", initials: "NS", what: "collected Gold Rush · 185 of 250", when: 2 * MIN },
|
|
3969
|
+
{ id: "ac_02", who: "Grace Hopper", initials: "GH", what: "collected Dusk, no. 4 · 61 of 150", when: 26 * MIN },
|
|
3970
|
+
{ id: "ac_03", who: "Frida Kahlo", initials: "FK", what: "tipped $20 on Tidewater", when: 1 * HOUR },
|
|
3971
|
+
{ id: "ac_04", who: "Mary Shelley", initials: "MS", what: "started following you", when: 3 * HOUR },
|
|
3972
|
+
{ id: "ac_05", who: "Nina Simone", initials: "NS", what: "set a reminder for Night Signal", when: 6 * HOUR },
|
|
3663
3973
|
]
|
|
3664
3974
|
|
|
3665
3975
|
export function DashboardPage() {
|
|
@@ -3667,7 +3977,17 @@ export function DashboardPage() {
|
|
|
3667
3977
|
<AppShell
|
|
3668
3978
|
// Sidebar hides below lg, so the same destinations ride in the shell's
|
|
3669
3979
|
// mobile slots — without them the route has no navigation under 1024px.
|
|
3670
|
-
header={
|
|
3980
|
+
header={
|
|
3981
|
+
<AppHeader
|
|
3982
|
+
title={
|
|
3983
|
+
<Row gap={1} align="center">
|
|
3984
|
+
<Icon name="gem" variant="solid" className="text-xl" />
|
|
3985
|
+
<span className="text-title-sm">Atelier</span>
|
|
3986
|
+
</Row>
|
|
3987
|
+
}
|
|
3988
|
+
actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
|
|
3989
|
+
/>
|
|
3990
|
+
}
|
|
3671
3991
|
bottomNav={
|
|
3672
3992
|
<BottomNav aria-label="Primary">
|
|
3673
3993
|
<BottomNavItem icon={<Icon name="grid" />} label="Overview" showLabel active render={<a href="/" />} />
|
|
@@ -3680,90 +4000,249 @@ export function DashboardPage() {
|
|
|
3680
4000
|
sidebar={
|
|
3681
4001
|
<Sidebar>
|
|
3682
4002
|
<SidebarHeader>
|
|
3683
|
-
<
|
|
4003
|
+
<Row gap={1} align="center" className="px-3">
|
|
4004
|
+
<Icon name="gem" variant="solid" className="text-xl" />
|
|
4005
|
+
<span className="text-title-sm">Atelier</span>
|
|
4006
|
+
</Row>
|
|
3684
4007
|
</SidebarHeader>
|
|
3685
4008
|
<SidebarNav>
|
|
3686
4009
|
<SidebarItem icon={<Icon name="grid" />} label="Overview" active render={<a href="/" />} />
|
|
3687
4010
|
<SidebarItem icon={<Icon name="image-stack" />} label="Editions" render={<a href="/editions" />} />
|
|
3688
4011
|
<SidebarItem icon={<Icon name="user" />} label="Collectors" render={<a href="/collectors" />} />
|
|
3689
|
-
<SidebarItem
|
|
4012
|
+
<SidebarItem
|
|
4013
|
+
icon={<Icon name="bell" />}
|
|
4014
|
+
label="Activity"
|
|
4015
|
+
badge={<NotificationBadge count={4} tone="neutral" />}
|
|
4016
|
+
render={<a href="/activity" />}
|
|
4017
|
+
/>
|
|
3690
4018
|
<SidebarItem icon={<Icon name="gear" />} label="Settings" render={<a href="/settings" />} />
|
|
3691
4019
|
</SidebarNav>
|
|
4020
|
+
{/* Entity and DotsMenu ink with page tokens; a theme can paint the rail
|
|
4021
|
+
dark on a light page, so this subtree reads the rail's ink instead. */}
|
|
4022
|
+
<SidebarFooter className="[--foreground:var(--sidebar-foreground)]">
|
|
4023
|
+
<Entity
|
|
4024
|
+
className="[--entity-py:0]"
|
|
4025
|
+
leading={<Avatar size="sm" fallback="AL" alt="" />}
|
|
4026
|
+
title="Ada Lovelace"
|
|
4027
|
+
action={
|
|
4028
|
+
<DotsMenu label="Account" size="icon-sm">
|
|
4029
|
+
<MenuItem render={<a href="/@ada" />}>View profile</MenuItem>
|
|
4030
|
+
<MenuItem render={<a href="/settings" />}>Settings</MenuItem>
|
|
4031
|
+
<MenuSeparator />
|
|
4032
|
+
<MenuItem>Sign out</MenuItem>
|
|
4033
|
+
</DotsMenu>
|
|
4034
|
+
}
|
|
4035
|
+
/>
|
|
4036
|
+
</SidebarFooter>
|
|
3692
4037
|
</Sidebar>
|
|
3693
4038
|
}
|
|
3694
4039
|
>
|
|
3695
4040
|
<Page max="wide" gap={4} className="py-6">
|
|
3696
4041
|
<PageHeader
|
|
3697
4042
|
title="Overview"
|
|
3698
|
-
description="Sales
|
|
4043
|
+
description="Sales and collector activity for the last 30 days."
|
|
3699
4044
|
actions={
|
|
3700
4045
|
<Row gap={2}>
|
|
3701
|
-
<Button variant="outline">
|
|
3702
|
-
|
|
4046
|
+
<Button variant="outline">
|
|
4047
|
+
<Icon name="download" />
|
|
4048
|
+
Export
|
|
4049
|
+
</Button>
|
|
4050
|
+
<Button>
|
|
4051
|
+
<Icon name="plus" />
|
|
4052
|
+
New edition
|
|
4053
|
+
</Button>
|
|
3703
4054
|
</Row>
|
|
3704
4055
|
}
|
|
3705
4056
|
/>
|
|
3706
|
-
<Grid cols={{ base:
|
|
4057
|
+
<Grid cols={{ base: 2, xl: 4 }} gap={3}>
|
|
3707
4058
|
{stats.map((stat) => (
|
|
3708
4059
|
<Card key={stat.label}>
|
|
3709
4060
|
<CardHeader>
|
|
3710
4061
|
<CardDescription>{stat.label}</CardDescription>
|
|
3711
4062
|
<CardTitle className="text-mono-xl">{stat.value}</CardTitle>
|
|
3712
|
-
<CardAction>
|
|
3713
|
-
<Badge tone={stat.up ? "success" : "neutral"} size="sm">
|
|
3714
|
-
<Icon name={stat.up ? "chevron-up" : "chevron-down"} />
|
|
3715
|
-
{stat.delta}
|
|
3716
|
-
</Badge>
|
|
3717
|
-
</CardAction>
|
|
3718
4063
|
</CardHeader>
|
|
3719
4064
|
<CardContent>
|
|
3720
|
-
<
|
|
4065
|
+
<Row gap={1} wrap>
|
|
4066
|
+
<Badge
|
|
4067
|
+
tone={stat.up ? "success" : "warning"}
|
|
4068
|
+
size="sm"
|
|
4069
|
+
icon={<Icon name={stat.up ? "arrow-up" : "arrow-down"} />}
|
|
4070
|
+
>
|
|
4071
|
+
{stat.delta}
|
|
4072
|
+
</Badge>
|
|
4073
|
+
<span className="text-caption text-muted-foreground">{stat.note}</span>
|
|
4074
|
+
</Row>
|
|
3721
4075
|
</CardContent>
|
|
3722
4076
|
</Card>
|
|
3723
4077
|
))}
|
|
3724
4078
|
</Grid>
|
|
3725
4079
|
<Grid cols={{ base: 1, xl: 3 }} gap={3}>
|
|
3726
|
-
<Card className="xl:col-span-2">
|
|
4080
|
+
<Card className="flex flex-col xl:col-span-2">
|
|
3727
4081
|
<CardHeader>
|
|
3728
|
-
<CardTitle>
|
|
3729
|
-
<CardDescription>
|
|
3730
|
-
<CardAction>
|
|
3731
|
-
<Button variant="ghost" size="sm" render={<a href="/deployments" />}>
|
|
3732
|
-
View all
|
|
3733
|
-
</Button>
|
|
3734
|
-
</CardAction>
|
|
4082
|
+
<CardTitle>Editions</CardTitle>
|
|
4083
|
+
<CardDescription>Sales across your releases, newest first.</CardDescription>
|
|
3735
4084
|
</CardHeader>
|
|
3736
|
-
<CardContent flush>
|
|
4085
|
+
<CardContent flush className="mt-4 flex-1">
|
|
3737
4086
|
<DataTable
|
|
3738
|
-
data={
|
|
4087
|
+
data={editions}
|
|
3739
4088
|
rowKey={(row) => row.id}
|
|
4089
|
+
defaultSort={{ key: "released", direction: "desc" }}
|
|
3740
4090
|
columns={[
|
|
3741
|
-
{ key: "
|
|
3742
|
-
{
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
4091
|
+
{ key: "title", header: "Edition", sortable: true, className: "text-label-md whitespace-nowrap" },
|
|
4092
|
+
{
|
|
4093
|
+
key: "status",
|
|
4094
|
+
header: "Status",
|
|
4095
|
+
cell: (row) => (
|
|
4096
|
+
<Badge appearance="plain" tone={STATUS_TONE[row.status as EditionStatus]} dot>
|
|
4097
|
+
{STATUS_LABEL[row.status as EditionStatus]}
|
|
4098
|
+
</Badge>
|
|
4099
|
+
),
|
|
4100
|
+
},
|
|
4101
|
+
{
|
|
4102
|
+
key: "sold",
|
|
4103
|
+
header: "Sold",
|
|
4104
|
+
sortable: true,
|
|
4105
|
+
sortValue: (row) => row.sold / row.supply,
|
|
4106
|
+
cell: (row) => (
|
|
4107
|
+
<Row gap={3} align="center" className="min-w-40">
|
|
4108
|
+
<Meter
|
|
4109
|
+
value={row.sold}
|
|
4110
|
+
max={row.supply}
|
|
4111
|
+
size="sm"
|
|
4112
|
+
tone={row.sold === row.supply ? "success" : "primary"}
|
|
4113
|
+
aria-label={`${row.title} sold`}
|
|
4114
|
+
getAriaValueText={(_, v) => `${v} of ${row.supply} sold`}
|
|
4115
|
+
className="flex-1"
|
|
4116
|
+
/>
|
|
4117
|
+
<span className="text-mono-caption whitespace-nowrap text-muted-foreground">
|
|
4118
|
+
{row.sold}/{row.supply}
|
|
4119
|
+
</span>
|
|
4120
|
+
</Row>
|
|
4121
|
+
),
|
|
4122
|
+
},
|
|
4123
|
+
{
|
|
4124
|
+
key: "revenue",
|
|
4125
|
+
header: "Revenue",
|
|
4126
|
+
align: "end",
|
|
4127
|
+
sortable: true,
|
|
4128
|
+
sortValue: (row) => row.sold * row.price,
|
|
4129
|
+
cell: (row) =>
|
|
4130
|
+
row.sold > 0 ? (
|
|
4131
|
+
<span className="text-mono-sm">{usd.format(row.sold * row.price)}</span>
|
|
4132
|
+
) : (
|
|
4133
|
+
<span className="text-mono-sm text-muted-foreground">—</span>
|
|
4134
|
+
),
|
|
4135
|
+
},
|
|
4136
|
+
{
|
|
4137
|
+
key: "released",
|
|
4138
|
+
header: "Release",
|
|
4139
|
+
align: "end",
|
|
4140
|
+
sortable: true,
|
|
4141
|
+
cell: (row) => (
|
|
4142
|
+
<span className="text-body-sm whitespace-nowrap text-muted-foreground">{shortDate(row.released)}</span>
|
|
4143
|
+
),
|
|
4144
|
+
},
|
|
3746
4145
|
]}
|
|
3747
4146
|
/>
|
|
3748
4147
|
</CardContent>
|
|
4148
|
+
<CardFooter className="justify-between">
|
|
4149
|
+
<span className="text-caption text-muted-foreground">
|
|
4150
|
+
Showing {editions.length} of {editionCount} editions
|
|
4151
|
+
</span>
|
|
4152
|
+
<Button variant="ghost" size="sm" render={<a href="/editions" />}>
|
|
4153
|
+
View all editions
|
|
4154
|
+
<Icon name="arrow-right" />
|
|
4155
|
+
</Button>
|
|
4156
|
+
</CardFooter>
|
|
3749
4157
|
</Card>
|
|
3750
|
-
<
|
|
4158
|
+
<Stack gap={3}>
|
|
4159
|
+
<Card>
|
|
4160
|
+
<CardHeader>
|
|
4161
|
+
<CardDescription>Next payout</CardDescription>
|
|
4162
|
+
<CardTitle className="text-mono-xl">{usdCents.format(payout.amount)}</CardTitle>
|
|
4163
|
+
<CardAction>
|
|
4164
|
+
<Badge tone="info" size="sm" icon={<Icon name="calendar" />}>
|
|
4165
|
+
{shortDate(payout.date)}
|
|
4166
|
+
</Badge>
|
|
4167
|
+
</CardAction>
|
|
4168
|
+
</CardHeader>
|
|
4169
|
+
<CardContent>
|
|
4170
|
+
<dl className="sable-list-dl text-body-sm">
|
|
4171
|
+
{payout.lines.map((line) => (
|
|
4172
|
+
<div key={line.label} className="contents">
|
|
4173
|
+
<dt>{line.label}</dt>
|
|
4174
|
+
<dd className={typeof line.value === "number" ? "text-end text-mono-sm" : "text-end"}>
|
|
4175
|
+
{typeof line.value === "number" ? usdCents.format(line.value) : line.value}
|
|
4176
|
+
</dd>
|
|
4177
|
+
</div>
|
|
4178
|
+
))}
|
|
4179
|
+
</dl>
|
|
4180
|
+
</CardContent>
|
|
4181
|
+
</Card>
|
|
4182
|
+
<Card>
|
|
4183
|
+
<CardHeader>
|
|
4184
|
+
<CardTitle>Top collectors</CardTitle>
|
|
4185
|
+
<CardDescription>By spend this month.</CardDescription>
|
|
4186
|
+
<CardAction>
|
|
4187
|
+
<Button variant="ghost" size="sm" render={<a href="/collectors" />}>
|
|
4188
|
+
View all
|
|
4189
|
+
</Button>
|
|
4190
|
+
</CardAction>
|
|
4191
|
+
</CardHeader>
|
|
4192
|
+
<CardList>
|
|
4193
|
+
{collectors.map((person) => (
|
|
4194
|
+
<Entity
|
|
4195
|
+
key={person.id}
|
|
4196
|
+
leading={<Avatar size="sm" fallback={person.initials} alt="" />}
|
|
4197
|
+
title={person.name}
|
|
4198
|
+
subtitle={`${person.handle} · ${person.pieces} pieces`}
|
|
4199
|
+
action={<span className="text-mono-sm">{usd.format(person.spent)}</span>}
|
|
4200
|
+
/>
|
|
4201
|
+
))}
|
|
4202
|
+
</CardList>
|
|
4203
|
+
</Card>
|
|
4204
|
+
</Stack>
|
|
4205
|
+
</Grid>
|
|
4206
|
+
<Grid cols={{ base: 1, xl: 3 }} gap={3}>
|
|
4207
|
+
<Card className="flex flex-col">
|
|
4208
|
+
<CardHeader>
|
|
4209
|
+
<CardTitle>Upcoming releases</CardTitle>
|
|
4210
|
+
<CardDescription>Scheduled to open to collectors.</CardDescription>
|
|
4211
|
+
</CardHeader>
|
|
4212
|
+
<CardList>
|
|
4213
|
+
{upcoming.map((release) => (
|
|
4214
|
+
<Entity
|
|
4215
|
+
key={release.id}
|
|
4216
|
+
title={release.title}
|
|
4217
|
+
subtitle={release.detail}
|
|
4218
|
+
action={<RelativeTime date={Date.now() + release.at} className="text-caption text-muted-foreground" />}
|
|
4219
|
+
/>
|
|
4220
|
+
))}
|
|
4221
|
+
</CardList>
|
|
4222
|
+
<CardFooter className="mt-auto">
|
|
4223
|
+
<Button variant="outline" size="sm" render={<a href="/editions/new" />}>
|
|
4224
|
+
<Icon name="calendar" />
|
|
4225
|
+
Schedule a release
|
|
4226
|
+
</Button>
|
|
4227
|
+
</CardFooter>
|
|
4228
|
+
</Card>
|
|
4229
|
+
<Card className="xl:col-span-2">
|
|
3751
4230
|
<CardHeader>
|
|
3752
4231
|
<CardTitle>Activity</CardTitle>
|
|
3753
|
-
<CardDescription>Collects,
|
|
4232
|
+
<CardDescription>Collects, tips, and follows.</CardDescription>
|
|
4233
|
+
<CardAction>
|
|
4234
|
+
<Button variant="ghost" size="sm" render={<a href="/activity" />}>
|
|
4235
|
+
View all
|
|
4236
|
+
</Button>
|
|
4237
|
+
</CardAction>
|
|
3754
4238
|
</CardHeader>
|
|
3755
4239
|
<CardList>
|
|
3756
4240
|
{activity.map((item) => (
|
|
3757
4241
|
<Entity
|
|
3758
4242
|
key={item.id}
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
<span className="text-body-sm">
|
|
3763
|
-
<span className="text-label-md">{item.who}</span>{" "}
|
|
3764
|
-
<span className="text-muted-foreground">{item.what}</span>
|
|
3765
|
-
</span>
|
|
3766
|
-
}
|
|
4243
|
+
leading={<Avatar size="sm" fallback={item.initials} alt="" />}
|
|
4244
|
+
title={item.who}
|
|
4245
|
+
subtitle={item.what}
|
|
3767
4246
|
action={<RelativeTime date={Date.now() - item.when} className="text-caption text-muted-foreground" />}
|
|
3768
4247
|
/>
|
|
3769
4248
|
))}
|
|
@@ -3780,103 +4259,284 @@ export function DashboardPage() {
|
|
|
3780
4259
|
|
|
3781
4260
|
# FeedPage
|
|
3782
4261
|
|
|
3783
|
-
|
|
4262
|
+
Social reading feed with a side rail: a feed-width main column (composer, For you / Following Tabs, one Card per post, an edition drop with price, supply Meter and Collect, a Skeleton for the next page) beside a sticky rail of who-to-follow and upcoming drops that drops away below lg.
|
|
3784
4263
|
|
|
3785
|
-
**Intent:** Reach for this for timelines, activity streams, and any reverse-chronological reading surface. Not for record management (DashboardPage) or side-by-side browsing (ListDetailPage).
|
|
4264
|
+
**Intent:** Reach for this for timelines, activity streams, and any reverse-chronological reading surface where people also act on what they read. Not for record management (DashboardPage) or side-by-side browsing (ListDetailPage).
|
|
3786
4265
|
|
|
3787
4266
|
## Notes
|
|
3788
4267
|
|
|
3789
|
-
-
|
|
3790
|
-
-
|
|
3791
|
-
- The
|
|
3792
|
-
-
|
|
3793
|
-
-
|
|
4268
|
+
- The main column is capped at `--region-feed` (640px) inside a `wide` page, so posts keep a reading measure while the rail fills the desktop width. The rail is a fixed `w-80` aside, `sticky` at lg and `hidden` below it — its content is supplementary, so nothing is lost on mobile.
|
|
4269
|
+
- Feed scope is `Tabs` (underline, stretched to the column with `flex`): peer views of the same feed. The composer sits above them because posting applies to both.
|
|
4270
|
+
- The composer is a Card holding `Textarea autoResize` plus ghost icon Buttons and a secondary `Post` — not ChatComposer, which submits on Enter and is for conversations.
|
|
4271
|
+
- Each post is a Card: an Entity byline in a CardList (with a DotsMenu for post actions), body copy, then media in an AspectBox at the asset's own ratio, inset with `rounded-concentric-lg/6` so its corners follow the card's in every theme.
|
|
4272
|
+
- An edition drop adds a `Badge appearance="overlay"` on the artwork, title and price, the one primary Button on the page (`Collect`), and a `Meter` for supply — collected against a known maximum is a level, not task progress.
|
|
4273
|
+
- Actions are ghost sm Buttons (icon + count) in a Row pulled out by the button's own padding (`-ms-3`) so the first icon sits on the text edge.
|
|
4274
|
+
- Loading the next page shows a Skeleton post in the shape the post will land in, above the explicit load-more Button in its `loading` state — predictable for keyboard and AT users, unlike infinite scroll.
|
|
3794
4275
|
|
|
3795
4276
|
## Accessibility
|
|
3796
4277
|
|
|
3797
|
-
- Each post is an <article>, so AT users can jump between posts.
|
|
3798
|
-
-
|
|
3799
|
-
-
|
|
4278
|
+
- Each post is an <article>, so AT users can jump between posts; the rail is a labelled <aside> landmark.
|
|
4279
|
+
- Rail card titles carry role="heading" aria-level=2 under the page's one h1.
|
|
4280
|
+
- Icon-only buttons and every Follow / Remind button carry aria-labels naming their target; counts stay in the accessible name.
|
|
4281
|
+
- The supply Meter is role=meter with a getAriaValueText reading ("62 of 100 collected").
|
|
4282
|
+
- The Skeleton card is aria-busy; the loading Button keeps its label ("Loading posts").
|
|
3800
4283
|
|
|
3801
4284
|
## Source
|
|
3802
4285
|
|
|
3803
4286
|
```tsx
|
|
3804
|
-
import { AspectBox, Avatar, Button, Card, CardContent, CardList, Entity, Icon, Page, PageHeader, Row, Stack } from "@sableui/react"
|
|
4287
|
+
import { AspectBox, Avatar, Badge, Button, Card, CardContent, CardHeader, CardList, CardTitle, DotsMenu, Entity, Icon, MenuItem, MenuSeparator, Meter, MeterLabel, MeterTrack, MeterValue, Page, PageHeader, Row, Skeleton, Stack, Tabs, TabsList, TabsPanel, TabsTrigger, Textarea } from "@sableui/react"
|
|
4288
|
+
|
|
4289
|
+
type Post = {
|
|
4290
|
+
id: string
|
|
4291
|
+
author: string
|
|
4292
|
+
handle: string
|
|
4293
|
+
initials: string
|
|
4294
|
+
time: string
|
|
4295
|
+
following: boolean
|
|
4296
|
+
body: string
|
|
4297
|
+
media?: { src: string; alt: string }
|
|
4298
|
+
drop?: { title: string; price: string; collected: number; supply: number }
|
|
4299
|
+
likes: number
|
|
4300
|
+
comments: number
|
|
4301
|
+
}
|
|
3805
4302
|
|
|
3806
|
-
const posts = [
|
|
4303
|
+
const posts: Post[] = [
|
|
3807
4304
|
{
|
|
3808
4305
|
id: "p_01",
|
|
3809
4306
|
author: "Ada Lovelace",
|
|
3810
|
-
handle: "@ada
|
|
4307
|
+
handle: "@ada",
|
|
3811
4308
|
initials: "AL",
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
4309
|
+
time: "2h",
|
|
4310
|
+
following: true,
|
|
4311
|
+
body: "Gold Rush is live. 100 signed prints, each paired with the on-chain original. Thank you for waiting.",
|
|
4312
|
+
media: { src: "/media/edition-cover.svg", alt: "Gold Rush, a gold-leaf study in overlapping arcs" },
|
|
4313
|
+
drop: { title: "Gold Rush", price: "$120", collected: 62, supply: 100 },
|
|
3815
4314
|
likes: 128,
|
|
3816
4315
|
comments: 24,
|
|
3817
4316
|
},
|
|
3818
4317
|
{
|
|
3819
4318
|
id: "p_02",
|
|
3820
4319
|
author: "Grace Hopper",
|
|
3821
|
-
handle: "@grace
|
|
4320
|
+
handle: "@grace",
|
|
3822
4321
|
initials: "GH",
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
4322
|
+
time: "5h",
|
|
4323
|
+
following: true,
|
|
4324
|
+
body: "Process shot from this week. Compilers and copper ink — the plotter ran for eleven hours on this one.",
|
|
4325
|
+
media: { src: "/media/process-shot.svg", alt: "Work-in-progress plotter drawing in copper ink" },
|
|
3826
4326
|
likes: 86,
|
|
3827
4327
|
comments: 9,
|
|
3828
4328
|
},
|
|
4329
|
+
{
|
|
4330
|
+
id: "p_03",
|
|
4331
|
+
author: "Nina Simone",
|
|
4332
|
+
handle: "@nina",
|
|
4333
|
+
initials: "NS",
|
|
4334
|
+
time: "8h",
|
|
4335
|
+
following: false,
|
|
4336
|
+
body: "Collectors of Dusk, no. 3 — your unlockable score is now in your library. Headphones on for this one.",
|
|
4337
|
+
likes: 214,
|
|
4338
|
+
comments: 31,
|
|
4339
|
+
},
|
|
4340
|
+
]
|
|
4341
|
+
|
|
4342
|
+
const suggestions = [
|
|
4343
|
+
{ id: "u_01", name: "Mary Shelley", handle: "@mary", initials: "MS" },
|
|
4344
|
+
{ id: "u_02", name: "Nina Simone", handle: "@nina", initials: "NS" },
|
|
4345
|
+
]
|
|
4346
|
+
|
|
4347
|
+
const upcoming = [
|
|
4348
|
+
{ id: "d_01", title: "Quiet Harbor", artist: "Mary Shelley", initials: "MS", date: "Sat, Oct 12" },
|
|
4349
|
+
{ id: "d_02", title: "Dusk, no. 4", artist: "Nina Simone", initials: "NS", date: "Tue, Oct 15" },
|
|
3829
4350
|
]
|
|
3830
4351
|
|
|
4352
|
+
const renderPost = (post: Post) => (
|
|
4353
|
+
<article key={post.id}>
|
|
4354
|
+
<Card>
|
|
4355
|
+
<CardList>
|
|
4356
|
+
<Entity
|
|
4357
|
+
leading={<Avatar fallback={post.initials} alt={post.author} />}
|
|
4358
|
+
title={post.author}
|
|
4359
|
+
subtitle={`${post.handle} · ${post.time}`}
|
|
4360
|
+
action={
|
|
4361
|
+
<DotsMenu label={`Post options for ${post.author}`} size="icon-sm">
|
|
4362
|
+
<MenuItem>Copy link</MenuItem>
|
|
4363
|
+
<MenuItem>Mute {post.handle}</MenuItem>
|
|
4364
|
+
<MenuSeparator />
|
|
4365
|
+
<MenuItem variant="destructive">Report post</MenuItem>
|
|
4366
|
+
</DotsMenu>
|
|
4367
|
+
}
|
|
4368
|
+
/>
|
|
4369
|
+
</CardList>
|
|
4370
|
+
<CardContent className="flex flex-col gap-4 pt-0">
|
|
4371
|
+
<p className="text-body-md text-foreground">{post.body}</p>
|
|
4372
|
+
{post.media ? (
|
|
4373
|
+
<AspectBox ratio={16 / 9} className="overflow-hidden rounded-concentric-lg/6 bg-muted">
|
|
4374
|
+
<img src={post.media.src} alt={post.media.alt} className="size-full object-cover" />
|
|
4375
|
+
{post.drop ? (
|
|
4376
|
+
<div className="absolute start-3 top-3">
|
|
4377
|
+
<Badge appearance="overlay" size="sm" dot>
|
|
4378
|
+
Live drop
|
|
4379
|
+
</Badge>
|
|
4380
|
+
</div>
|
|
4381
|
+
) : null}
|
|
4382
|
+
</AspectBox>
|
|
4383
|
+
) : null}
|
|
4384
|
+
{post.drop ? (
|
|
4385
|
+
<Stack gap={1.5}>
|
|
4386
|
+
<Row gap={2} align="center" justify="between">
|
|
4387
|
+
<div className="min-w-0">
|
|
4388
|
+
<p className="text-title-md text-foreground">{post.drop.title}</p>
|
|
4389
|
+
<p className="text-body-sm text-muted-foreground">
|
|
4390
|
+
{`${post.drop.price} · Edition of ${post.drop.supply}`}
|
|
4391
|
+
</p>
|
|
4392
|
+
</div>
|
|
4393
|
+
<Button>{`Collect for ${post.drop.price}`}</Button>
|
|
4394
|
+
</Row>
|
|
4395
|
+
<Meter
|
|
4396
|
+
value={post.drop.collected}
|
|
4397
|
+
max={post.drop.supply}
|
|
4398
|
+
size="sm"
|
|
4399
|
+
getAriaValueText={(_, value) => `${value} of ${post.drop?.supply} collected`}
|
|
4400
|
+
>
|
|
4401
|
+
<Row gap={2} align="center" justify="between" className="mb-2">
|
|
4402
|
+
<MeterLabel>{`${post.drop.collected} collected`}</MeterLabel>
|
|
4403
|
+
<MeterValue>{(_, value) => `${(post.drop?.supply ?? 0) - value} left`}</MeterValue>
|
|
4404
|
+
</Row>
|
|
4405
|
+
<MeterTrack />
|
|
4406
|
+
</Meter>
|
|
4407
|
+
</Stack>
|
|
4408
|
+
) : null}
|
|
4409
|
+
{/* Pulled out by exactly a sm Button's own padding (px-3), so the
|
|
4410
|
+
first icon lands on the text edge above it. */}
|
|
4411
|
+
<Row gap={0.5} className="-ms-3">
|
|
4412
|
+
<Button variant="ghost" size="sm" aria-label={`Like, ${post.likes} likes`}>
|
|
4413
|
+
<Icon name="heart" />
|
|
4414
|
+
{post.likes}
|
|
4415
|
+
</Button>
|
|
4416
|
+
<Button variant="ghost" size="sm" aria-label={`Comment, ${post.comments} comments`}>
|
|
4417
|
+
<Icon name="comment" />
|
|
4418
|
+
{post.comments}
|
|
4419
|
+
</Button>
|
|
4420
|
+
<Button variant="ghost" size="sm" aria-label="Share">
|
|
4421
|
+
<Icon name="share" />
|
|
4422
|
+
</Button>
|
|
4423
|
+
</Row>
|
|
4424
|
+
</CardContent>
|
|
4425
|
+
</Card>
|
|
4426
|
+
</article>
|
|
4427
|
+
)
|
|
4428
|
+
|
|
3831
4429
|
export function FeedPage() {
|
|
3832
4430
|
return (
|
|
3833
|
-
<Page as="main" max="
|
|
3834
|
-
<
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
<
|
|
3838
|
-
<
|
|
3839
|
-
{
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
4431
|
+
<Page as="main" max="wide" gap={4} className="py-6">
|
|
4432
|
+
<Row gap={4} align="start" justify="center">
|
|
4433
|
+
<Stack gap={3} className="min-w-0 max-w-(--region-feed) flex-1">
|
|
4434
|
+
<PageHeader title="Home" />
|
|
4435
|
+
<Card>
|
|
4436
|
+
<CardContent>
|
|
4437
|
+
<Row gap={1.5} align="start">
|
|
4438
|
+
<Avatar fallback="AL" alt="Ada Lovelace" />
|
|
4439
|
+
<Stack gap={1.5} className="min-w-0 flex-1">
|
|
4440
|
+
<Textarea
|
|
4441
|
+
autoResize
|
|
4442
|
+
rows={2}
|
|
4443
|
+
aria-label="New post"
|
|
4444
|
+
placeholder="Share a process shot or an upcoming drop"
|
|
4445
|
+
/>
|
|
4446
|
+
<Row gap={1} align="center" justify="between">
|
|
4447
|
+
<Row gap={0.5}>
|
|
4448
|
+
<Button variant="ghost" size="icon-sm" aria-label="Add images">
|
|
4449
|
+
<Icon name="image-stack" />
|
|
4450
|
+
</Button>
|
|
4451
|
+
<Button variant="ghost" size="icon-sm" aria-label="Attach an edition">
|
|
4452
|
+
<Icon name="gem" />
|
|
4453
|
+
</Button>
|
|
4454
|
+
</Row>
|
|
4455
|
+
<Button variant="secondary" size="sm">
|
|
4456
|
+
Post
|
|
3849
4457
|
</Button>
|
|
3850
|
-
|
|
3851
|
-
|
|
4458
|
+
</Row>
|
|
4459
|
+
</Stack>
|
|
4460
|
+
</Row>
|
|
4461
|
+
</CardContent>
|
|
4462
|
+
</Card>
|
|
4463
|
+
<Tabs defaultValue="for-you">
|
|
4464
|
+
<TabsList className="flex">
|
|
4465
|
+
<TabsTrigger value="for-you">For you</TabsTrigger>
|
|
4466
|
+
<TabsTrigger value="following">Following</TabsTrigger>
|
|
4467
|
+
</TabsList>
|
|
4468
|
+
<TabsPanel value="for-you">
|
|
4469
|
+
<Stack gap={3}>
|
|
4470
|
+
{posts.map(renderPost)}
|
|
4471
|
+
{/* The next page, in the shape it will land in, while it fetches. */}
|
|
4472
|
+
<Card aria-busy="true">
|
|
4473
|
+
<CardContent className="flex flex-col gap-4">
|
|
4474
|
+
<Row gap={1.5} align="center">
|
|
4475
|
+
<Skeleton className="size-10 rounded-full" />
|
|
4476
|
+
<Stack gap={1}>
|
|
4477
|
+
<Skeleton className="h-3 w-32" />
|
|
4478
|
+
<Skeleton className="h-3 w-20" />
|
|
4479
|
+
</Stack>
|
|
4480
|
+
</Row>
|
|
4481
|
+
<Skeleton className="h-3 w-full" />
|
|
4482
|
+
<Skeleton className="h-3 w-2/3" />
|
|
4483
|
+
</CardContent>
|
|
4484
|
+
</Card>
|
|
4485
|
+
<Button variant="secondary" loading loadingLabel="Loading posts">
|
|
4486
|
+
Load more posts
|
|
4487
|
+
</Button>
|
|
4488
|
+
</Stack>
|
|
4489
|
+
</TabsPanel>
|
|
4490
|
+
<TabsPanel value="following">
|
|
4491
|
+
<Stack gap={3}>{posts.filter((post) => post.following).map(renderPost)}</Stack>
|
|
4492
|
+
</TabsPanel>
|
|
4493
|
+
</Tabs>
|
|
4494
|
+
</Stack>
|
|
4495
|
+
<aside aria-label="Suggestions" className="sticky top-6 hidden w-80 shrink-0 lg:block">
|
|
4496
|
+
<Stack gap={3}>
|
|
4497
|
+
<Card>
|
|
4498
|
+
<CardHeader>
|
|
4499
|
+
<CardTitle role="heading" aria-level={2}>Who to follow</CardTitle>
|
|
4500
|
+
</CardHeader>
|
|
4501
|
+
<CardList>
|
|
4502
|
+
{suggestions.map((person) => (
|
|
4503
|
+
<Entity
|
|
4504
|
+
key={person.id}
|
|
4505
|
+
leading={<Avatar fallback={person.initials} alt={person.name} />}
|
|
4506
|
+
title={person.name}
|
|
4507
|
+
subtitle={person.handle}
|
|
4508
|
+
action={
|
|
4509
|
+
<Button variant="secondary" size="sm" aria-label={`Follow ${person.name}`}>
|
|
4510
|
+
Follow
|
|
4511
|
+
</Button>
|
|
4512
|
+
}
|
|
4513
|
+
/>
|
|
4514
|
+
))}
|
|
3852
4515
|
</CardList>
|
|
3853
|
-
<AspectBox ratio={16 / 9}>
|
|
3854
|
-
<img src={post.image} alt={post.alt} className="size-full object-cover" />
|
|
3855
|
-
</AspectBox>
|
|
3856
|
-
<CardContent className="flex flex-col gap-2">
|
|
3857
|
-
<p className="text-body-md text-foreground">{post.body}</p>
|
|
3858
|
-
{/* Pulled out by exactly a sm Button's own padding (px-3), so the
|
|
3859
|
-
first icon lands on the text edge above it. Both are on the
|
|
3860
|
-
spacing scale, so they stay aligned at every density. */}
|
|
3861
|
-
<Row gap={1} className="-ms-3">
|
|
3862
|
-
<Button variant="ghost" size="sm" aria-label={`Like — ${post.likes}`}>
|
|
3863
|
-
<Icon name="heart" />
|
|
3864
|
-
{post.likes}
|
|
3865
|
-
</Button>
|
|
3866
|
-
<Button variant="ghost" size="sm" aria-label={`Comments — ${post.comments}`}>
|
|
3867
|
-
<Icon name="comment" />
|
|
3868
|
-
{post.comments}
|
|
3869
|
-
</Button>
|
|
3870
|
-
<Button variant="ghost" size="sm" aria-label="Share">
|
|
3871
|
-
<Icon name="share" />
|
|
3872
|
-
</Button>
|
|
3873
|
-
</Row>
|
|
3874
|
-
</CardContent>
|
|
3875
4516
|
</Card>
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
4517
|
+
<Card>
|
|
4518
|
+
<CardHeader>
|
|
4519
|
+
<CardTitle role="heading" aria-level={2}>Upcoming drops</CardTitle>
|
|
4520
|
+
</CardHeader>
|
|
4521
|
+
<CardList>
|
|
4522
|
+
{upcoming.map((drop) => (
|
|
4523
|
+
<Entity
|
|
4524
|
+
key={drop.id}
|
|
4525
|
+
leading={<Avatar fallback={drop.initials} alt={drop.artist} />}
|
|
4526
|
+
title={drop.title}
|
|
4527
|
+
subtitle={`${drop.artist} · ${drop.date}`}
|
|
4528
|
+
action={
|
|
4529
|
+
<Button variant="ghost" size="icon-sm" aria-label={`Remind me about ${drop.title}`}>
|
|
4530
|
+
<Icon name="bell" />
|
|
4531
|
+
</Button>
|
|
4532
|
+
}
|
|
4533
|
+
/>
|
|
4534
|
+
))}
|
|
4535
|
+
</CardList>
|
|
4536
|
+
</Card>
|
|
4537
|
+
</Stack>
|
|
4538
|
+
</aside>
|
|
4539
|
+
</Row>
|
|
3880
4540
|
</Page>
|
|
3881
4541
|
)
|
|
3882
4542
|
}
|
|
@@ -3886,120 +4546,518 @@ export function FeedPage() {
|
|
|
3886
4546
|
|
|
3887
4547
|
# LandingPage
|
|
3888
4548
|
|
|
3889
|
-
|
|
4549
|
+
Product / open-source landing page: a centered hero with an eyebrow Badge, one display headline, lede, two CTAs and a copyable install command; a Browser-framed product preview composed from real components; a six-up feature grid; a setup section with tabbed code; an agent-workflow section with terminal output; a CTA card; and a link-column footer.
|
|
3890
4550
|
|
|
3891
|
-
**Intent:** Reach for this for the marketing
|
|
4551
|
+
**Intent:** Reach for this for the marketing route of a developer product, library, or tool: the page where the product shows itself working and the reader installs or signs up. For a branded, editorial marketing site use BrandLandingPage. Not for product UI (display type and one-per-view rules forbid it inside the app).
|
|
3892
4552
|
|
|
3893
4553
|
## Notes
|
|
3894
4554
|
|
|
3895
|
-
- Display type is the marketing voice: exactly one display headline per page
|
|
3896
|
-
- The eyebrow is a small outline Badge (`size="sm"`)
|
|
3897
|
-
- The lede is body-lg capped at the prose measure
|
|
3898
|
-
- Two CTAs max: one primary, one quiet outline.
|
|
3899
|
-
-
|
|
3900
|
-
-
|
|
4555
|
+
- Display type is the marketing voice: exactly one display headline per page (`text-display-lg` on phones, `text-display-2xl` from `sm`). Every section below uses `text-heading-1` with a `text-overline` kicker.
|
|
4556
|
+
- The eyebrow is a small outline Badge (`size="sm"`): the badge's own size, never a type class forced onto its label. Hierarchy above the fold is eyebrow, display, lede, CTAs, install command.
|
|
4557
|
+
- The lede is body-lg capped at the prose measure. The headline may be wide; reading text may not.
|
|
4558
|
+
- Two CTAs max per row: one primary, one quiet outline. The install line is a `CodeBlock` with `prompt="$"`, so it truncates on one line and copy takes only the command.
|
|
4559
|
+
- Show the product instead of describing it. The preview is real components (PageHeader, metric Cards, a Meter, a CardList of Entity rows, Message and Bubble, ChatComposer) inside `Browser`, wrapped in an `inert` div so the picture's controls never take focus. Secondary columns hide on small screens rather than stacking into a long mock.
|
|
4560
|
+
- Features are Cards in a fixed `Grid cols={{ base: 1, sm: 2, lg: 3 }}`, so six items always land as full rows (3 x 2, 2 x 3, 1 x 6) without an orphan.
|
|
4561
|
+
- Multi-line code uses `CodeBlock` with a filename; switch related snippets with `Tabs`, defaulting to the most complete one. Terminal output passes `highlight={false}` so a code highlighter does not colour prose.
|
|
4562
|
+
- Reveal wraps below-the-fold blocks only (features, code, CTA). It no-ops under reduced motion via the motion policy.
|
|
4563
|
+
- Hide nav groups on narrow screens by wrapping them in a `max-md:hidden` div: a `Row` or `Button` sets its own display, which a `hidden` class on the same element does not reliably override.
|
|
3901
4564
|
|
|
3902
4565
|
## Accessibility
|
|
3903
4566
|
|
|
3904
|
-
- The hero headline is the page h1; feature titles are h2s
|
|
4567
|
+
- The hero headline is the page h1; section titles are h2s and feature and step titles are h3s. Footer column labels are h2s inside the footer nav.
|
|
4568
|
+
- The product preview is a `figure` with a visible figcaption. Its content is `inert`, which removes it from the tab order and the accessibility tree, so its headings and buttons do not pollute the outline.
|
|
3905
4569
|
- Reveal renders content immediately (no animation) when prefers-reduced-motion is set.
|
|
3906
|
-
- CTA buttons are real links
|
|
4570
|
+
- CTA buttons are real links with visible text, with no icon-only CTAs. Footer links keep a 36px minimum row height for touch.
|
|
3907
4571
|
|
|
3908
4572
|
## Source
|
|
3909
4573
|
|
|
3910
4574
|
```tsx
|
|
3911
|
-
import { Badge, Button, Card, CardContent, Grid, Icon, Region, Reveal, Row, Stack } from "@sableui/react"
|
|
4575
|
+
import { Avatar, Badge, Browser, Bubble, Button, Card, CardAction, CardContent, CardDescription, CardHeader, CardList, CardTitle, ChatComposer, Code, CodeBlock, Entity, Grid, Icon, Message, Meter, MeterLabel, MeterTrack, MeterValue, PageHeader, Region, Reveal, Row, Stack, Tabs, TabsList, TabsPanel, TabsTrigger } from "@sableui/react"
|
|
3912
4576
|
|
|
3913
|
-
const
|
|
4577
|
+
const navLinks = [
|
|
4578
|
+
{ label: "Docs", href: "/docs" },
|
|
4579
|
+
{ label: "Components", href: "/components" },
|
|
4580
|
+
{ label: "Patterns", href: "/patterns" },
|
|
4581
|
+
{ label: "Themes", href: "/themes" },
|
|
4582
|
+
]
|
|
4583
|
+
|
|
4584
|
+
const deploys = [
|
|
3914
4585
|
{
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
4586
|
+
initials: "MO",
|
|
4587
|
+
name: "Maya Ortiz",
|
|
4588
|
+
title: "Add usage-based billing",
|
|
4589
|
+
meta: "main · a3f9c21 · 2m ago",
|
|
4590
|
+
status: "Building",
|
|
4591
|
+
tone: "info" as const,
|
|
4592
|
+
pulse: true,
|
|
3918
4593
|
},
|
|
4594
|
+
{
|
|
4595
|
+
initials: "SR",
|
|
4596
|
+
name: "Sam Reyes",
|
|
4597
|
+
title: "Fix search ranking for long queries",
|
|
4598
|
+
meta: "main · 81be04d · 18m ago",
|
|
4599
|
+
status: "Ready",
|
|
4600
|
+
tone: "success" as const,
|
|
4601
|
+
pulse: false,
|
|
4602
|
+
},
|
|
4603
|
+
{
|
|
4604
|
+
initials: "JL",
|
|
4605
|
+
name: "Jun Lee",
|
|
4606
|
+
title: "Rotate webhook signing keys",
|
|
4607
|
+
meta: "main · 5c7e9aa · 1h ago",
|
|
4608
|
+
status: "Ready",
|
|
4609
|
+
tone: "success" as const,
|
|
4610
|
+
pulse: false,
|
|
4611
|
+
},
|
|
4612
|
+
]
|
|
4613
|
+
|
|
4614
|
+
const features = [
|
|
3919
4615
|
{
|
|
3920
4616
|
icon: "component" as const,
|
|
3921
|
-
title: "Behavior
|
|
3922
|
-
body: "Focus, keyboard
|
|
4617
|
+
title: "Behavior built in",
|
|
4618
|
+
body: "Focus management, keyboard navigation, and ARIA come from Base UI primitives under every control.",
|
|
4619
|
+
},
|
|
4620
|
+
{
|
|
4621
|
+
icon: "eyedropper" as const,
|
|
4622
|
+
title: "Semantic tokens end to end",
|
|
4623
|
+
body: "Re-theme the whole product, light and dark, from one preset without touching a component.",
|
|
3923
4624
|
},
|
|
3924
4625
|
{
|
|
3925
4626
|
icon: "layout" as const,
|
|
3926
4627
|
title: "Pages, not just parts",
|
|
3927
|
-
body: "
|
|
4628
|
+
body: "Landing, dashboard, settings, auth, and list-detail shells to copy and adapt.",
|
|
4629
|
+
},
|
|
4630
|
+
{
|
|
4631
|
+
icon: "book" as const,
|
|
4632
|
+
title: "A contract agents can read",
|
|
4633
|
+
body: "registry.json, llms.txt, and a CLI hand agents the exact props, examples, and rules.",
|
|
4634
|
+
},
|
|
4635
|
+
{
|
|
4636
|
+
icon: "circle-check" as const,
|
|
4637
|
+
title: "Lint that knows the design",
|
|
4638
|
+
body: "sable-lint flags raw colors, hand-built controls, and motion that ignores reduced motion.",
|
|
4639
|
+
},
|
|
4640
|
+
{
|
|
4641
|
+
icon: "play" as const,
|
|
4642
|
+
title: "Motion with an off switch",
|
|
4643
|
+
body: "Named recipes run on one scale variable, so reduced motion stops every transition at once.",
|
|
4644
|
+
},
|
|
4645
|
+
]
|
|
4646
|
+
|
|
4647
|
+
const steps = [
|
|
4648
|
+
{
|
|
4649
|
+
title: "Install the package",
|
|
4650
|
+
body: "One dependency. React 19 and Tailwind v4 are the only peers.",
|
|
4651
|
+
},
|
|
4652
|
+
{
|
|
4653
|
+
title: "Import the styles",
|
|
4654
|
+
body: "Tailwind first, then Sable's tokens, then a source glob over the package.",
|
|
4655
|
+
},
|
|
4656
|
+
{
|
|
4657
|
+
title: "Compose your screens",
|
|
4658
|
+
body: "Build from components and layout primitives. Your theme does the rest.",
|
|
4659
|
+
},
|
|
4660
|
+
]
|
|
4661
|
+
|
|
4662
|
+
const stylesCode = `@import "tailwindcss";
|
|
4663
|
+
@import "@sableui/react/styles.css";
|
|
4664
|
+
@source "../node_modules/@sableui/react/dist/**/*.{js,mjs}";`
|
|
4665
|
+
|
|
4666
|
+
// design-lint-disable-next-line self-import -- a consumer code sample displayed on the page, not an import
|
|
4667
|
+
const composeCode = `import { Button, Card, CardContent, CardHeader, CardTitle } from "@sableui/react"
|
|
4668
|
+
|
|
4669
|
+
export function DeployCard() {
|
|
4670
|
+
return (
|
|
4671
|
+
<Card>
|
|
4672
|
+
<CardHeader>
|
|
4673
|
+
<CardTitle>Production</CardTitle>
|
|
4674
|
+
</CardHeader>
|
|
4675
|
+
<CardContent>
|
|
4676
|
+
<Button>Deploy</Button>
|
|
4677
|
+
</CardContent>
|
|
4678
|
+
</Card>
|
|
4679
|
+
)
|
|
4680
|
+
}`
|
|
4681
|
+
|
|
4682
|
+
const terminalCode = `$ npx sable search "confirm before deleting"
|
|
4683
|
+
component DestructiveActionModal
|
|
4684
|
+
component Dialog
|
|
4685
|
+
|
|
4686
|
+
$ npx sable component DestructiveActionModal --props
|
|
4687
|
+
props, a11y contract, composed examples
|
|
4688
|
+
|
|
4689
|
+
$ npx sable lint src
|
|
4690
|
+
0 findings in 42 files`
|
|
4691
|
+
|
|
4692
|
+
const agentPoints = [
|
|
4693
|
+
"Search ranks components, patterns, tokens, and docs for a plain-language need.",
|
|
4694
|
+
"Every component ships its props, accessibility contract, and composed examples.",
|
|
4695
|
+
"The same lint runs in the editor, in CI, and inside the agent's own loop.",
|
|
4696
|
+
]
|
|
4697
|
+
|
|
4698
|
+
const footerGroups = [
|
|
4699
|
+
{
|
|
4700
|
+
title: "Product",
|
|
4701
|
+
links: [
|
|
4702
|
+
{ label: "Components", href: "/components" },
|
|
4703
|
+
{ label: "Patterns", href: "/patterns" },
|
|
4704
|
+
{ label: "Themes", href: "/themes" },
|
|
4705
|
+
{ label: "Canvas", href: "/canvas" },
|
|
4706
|
+
],
|
|
4707
|
+
},
|
|
4708
|
+
{
|
|
4709
|
+
title: "Resources",
|
|
4710
|
+
links: [
|
|
4711
|
+
{ label: "Documentation", href: "/docs" },
|
|
4712
|
+
{ label: "Changelog", href: "/changelog" },
|
|
4713
|
+
{ label: "llms.txt", href: "/llms.txt" },
|
|
4714
|
+
{ label: "registry.json", href: "/registry.json" },
|
|
4715
|
+
],
|
|
4716
|
+
},
|
|
4717
|
+
{
|
|
4718
|
+
title: "Community",
|
|
4719
|
+
links: [
|
|
4720
|
+
{ label: "GitHub", href: "/github" },
|
|
4721
|
+
{ label: "npm", href: "/npm" },
|
|
4722
|
+
{ label: "Discussions", href: "/discussions" },
|
|
4723
|
+
],
|
|
3928
4724
|
},
|
|
3929
4725
|
]
|
|
3930
4726
|
|
|
3931
4727
|
export function LandingPage() {
|
|
3932
4728
|
return (
|
|
3933
|
-
<div className="min-h-dvh bg-background">
|
|
3934
|
-
<header>
|
|
3935
|
-
<Region as="nav" aria-label="Main" className="grid h-16 grid-cols-[1fr_auto_1fr] items-center">
|
|
3936
|
-
<
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
4729
|
+
<div className="min-h-dvh bg-background text-foreground">
|
|
4730
|
+
<header className="border-b border-border">
|
|
4731
|
+
<Region as="nav" aria-label="Main" max="wide" className="grid h-16 grid-cols-[1fr_auto_1fr] items-center">
|
|
4732
|
+
<a href="/" className="justify-self-start text-title-lg motion-interactive hover:text-muted-foreground">
|
|
4733
|
+
Sable
|
|
4734
|
+
</a>
|
|
4735
|
+
<div className="justify-self-center max-md:hidden">
|
|
4736
|
+
<Row gap={1}>
|
|
4737
|
+
{navLinks.map((link) => (
|
|
4738
|
+
<Button key={link.label} variant="ghost" size="sm" render={<a href={link.href} />}>
|
|
4739
|
+
{link.label}
|
|
4740
|
+
</Button>
|
|
4741
|
+
))}
|
|
4742
|
+
</Row>
|
|
4743
|
+
</div>
|
|
4744
|
+
<Row gap={1} className="col-start-3 justify-self-end">
|
|
4745
|
+
<div className="max-sm:hidden">
|
|
4746
|
+
<Button variant="ghost" size="sm" render={<a href="/github" />}>
|
|
4747
|
+
GitHub
|
|
4748
|
+
</Button>
|
|
4749
|
+
</div>
|
|
4750
|
+
<Button size="sm" render={<a href="/docs/getting-started" />}>
|
|
4751
|
+
Get started
|
|
3946
4752
|
</Button>
|
|
3947
4753
|
</Row>
|
|
3948
|
-
<Button size="sm" className="justify-self-end" render={<a href="/signup" />}>
|
|
3949
|
-
Get started
|
|
3950
|
-
</Button>
|
|
3951
4754
|
</Region>
|
|
3952
4755
|
</header>
|
|
4756
|
+
|
|
3953
4757
|
<main>
|
|
3954
|
-
<Region as="section" max="wide" className="
|
|
3955
|
-
<Stack gap={
|
|
3956
|
-
<
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
<
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
4758
|
+
<Region as="section" max="wide" className="pb-16 pt-16 sm:pt-24">
|
|
4759
|
+
<Stack gap={8} align="center">
|
|
4760
|
+
<Stack gap={4} align="center" className="text-center">
|
|
4761
|
+
<Badge appearance="outline" size="sm" icon={<Icon name="sparkles" />}>
|
|
4762
|
+
Open source · MIT licensed
|
|
4763
|
+
</Badge>
|
|
4764
|
+
<h1 className="max-w-4xl text-balance text-display-lg sm:text-display-2xl">
|
|
4765
|
+
Product UI your agents can't get wrong
|
|
4766
|
+
</h1>
|
|
4767
|
+
<p className="max-w-(--measure-prose) text-pretty text-body-lg text-muted-foreground">
|
|
4768
|
+
Sable is a themeable React component library with enforced tokens,
|
|
4769
|
+
typography, and motion, documented for coding agents as carefully
|
|
4770
|
+
as for people.
|
|
4771
|
+
</p>
|
|
4772
|
+
<Row gap={2} justify="center" wrap className="pt-2">
|
|
4773
|
+
<Button size="cta" render={<a href="/docs/getting-started" />}>
|
|
4774
|
+
Get started
|
|
4775
|
+
</Button>
|
|
4776
|
+
<Button size="cta" variant="outline" render={<a href="/components" />}>
|
|
4777
|
+
Browse components
|
|
4778
|
+
</Button>
|
|
4779
|
+
</Row>
|
|
4780
|
+
</Stack>
|
|
4781
|
+
<div className="w-full max-w-sm">
|
|
4782
|
+
<CodeBlock prompt="$" code="pnpm add @sableui/react" />
|
|
4783
|
+
</div>
|
|
3972
4784
|
</Stack>
|
|
3973
4785
|
</Region>
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
4786
|
+
|
|
4787
|
+
<Region as="section" aria-label="Product preview" max="wide" className="pb-20 sm:pb-28">
|
|
4788
|
+
<figure>
|
|
4789
|
+
<Browser url="atlas.example/deploys" className="shadow-xl">
|
|
4790
|
+
{/* A picture of software, not live UI: inert keeps its controls
|
|
4791
|
+
out of the tab order and the accessibility tree. */}
|
|
4792
|
+
<div inert className="grid gap-4 p-4 sm:p-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
|
4793
|
+
<Stack gap={4} className="min-w-0">
|
|
4794
|
+
<PageHeader
|
|
4795
|
+
titleAs="h2"
|
|
4796
|
+
title="Deployments"
|
|
4797
|
+
description="atlas-web · production"
|
|
4798
|
+
actions={
|
|
4799
|
+
<Row gap={2}>
|
|
4800
|
+
<Button variant="outline" size="sm">
|
|
4801
|
+
View logs
|
|
4802
|
+
</Button>
|
|
4803
|
+
<Button size="sm" iconStart={<Icon name="upload" />}>
|
|
4804
|
+
Deploy
|
|
4805
|
+
</Button>
|
|
4806
|
+
</Row>
|
|
4807
|
+
}
|
|
4808
|
+
/>
|
|
4809
|
+
<Grid cols={{ base: 2, md: 3 }} gap={3}>
|
|
4810
|
+
<Card density="compact">
|
|
4811
|
+
<CardHeader>
|
|
4812
|
+
<CardTitle>Uptime</CardTitle>
|
|
4813
|
+
<CardDescription>Last 30 days</CardDescription>
|
|
4814
|
+
</CardHeader>
|
|
4815
|
+
<CardContent>
|
|
4816
|
+
<p className="text-heading-3">99.98%</p>
|
|
4817
|
+
</CardContent>
|
|
4818
|
+
</Card>
|
|
4819
|
+
<Card density="compact">
|
|
4820
|
+
<CardHeader>
|
|
4821
|
+
<CardTitle>p95 latency</CardTitle>
|
|
4822
|
+
<CardDescription>Edge, all regions</CardDescription>
|
|
4823
|
+
</CardHeader>
|
|
4824
|
+
<CardContent>
|
|
4825
|
+
<p className="text-heading-3">182 ms</p>
|
|
4826
|
+
</CardContent>
|
|
4827
|
+
</Card>
|
|
4828
|
+
<Card density="compact" className="max-md:hidden">
|
|
4829
|
+
<CardHeader>
|
|
4830
|
+
<CardTitle>Build minutes</CardTitle>
|
|
4831
|
+
<CardDescription>Team plan</CardDescription>
|
|
4832
|
+
</CardHeader>
|
|
4833
|
+
<CardContent>
|
|
4834
|
+
<Meter value={64} tone="standard">
|
|
4835
|
+
<Row justify="between" wrap className="mb-2">
|
|
4836
|
+
<MeterLabel>Used</MeterLabel>
|
|
4837
|
+
<MeterValue>{(_, value) => `${(value * 30).toLocaleString("en-US")} of 3,000`}</MeterValue>
|
|
4838
|
+
</Row>
|
|
4839
|
+
<MeterTrack />
|
|
4840
|
+
</Meter>
|
|
4841
|
+
</CardContent>
|
|
4842
|
+
</Card>
|
|
4843
|
+
</Grid>
|
|
4844
|
+
<Card>
|
|
4845
|
+
<CardHeader>
|
|
4846
|
+
<CardTitle>Recent deploys</CardTitle>
|
|
4847
|
+
<CardDescription>Production branch</CardDescription>
|
|
4848
|
+
<CardAction>
|
|
4849
|
+
<Badge appearance="plain" tone="success" dot>
|
|
4850
|
+
Healthy
|
|
4851
|
+
</Badge>
|
|
4852
|
+
</CardAction>
|
|
4853
|
+
</CardHeader>
|
|
4854
|
+
<CardList>
|
|
4855
|
+
{deploys.map((deploy) => (
|
|
4856
|
+
<Entity
|
|
4857
|
+
key={deploy.meta}
|
|
4858
|
+
leading={<Avatar size="sm" fallback={deploy.initials} alt={deploy.name} />}
|
|
4859
|
+
title={deploy.title}
|
|
4860
|
+
subtitle={deploy.meta}
|
|
4861
|
+
action={
|
|
4862
|
+
<Badge appearance="plain" tone={deploy.tone} dot pulse={deploy.pulse}>
|
|
4863
|
+
{deploy.status}
|
|
4864
|
+
</Badge>
|
|
4865
|
+
}
|
|
4866
|
+
/>
|
|
4867
|
+
))}
|
|
4868
|
+
</CardList>
|
|
4869
|
+
</Card>
|
|
4870
|
+
</Stack>
|
|
4871
|
+
<Card className="max-lg:hidden">
|
|
4872
|
+
<CardHeader>
|
|
4873
|
+
<CardTitle>Release assistant</CardTitle>
|
|
4874
|
+
<CardDescription>Summaries from today's deploys</CardDescription>
|
|
4875
|
+
</CardHeader>
|
|
4876
|
+
<CardContent className="flex flex-col gap-4">
|
|
4877
|
+
<Message align="end">
|
|
4878
|
+
<Bubble variant="primary" align="end">
|
|
4879
|
+
What shipped to production today?
|
|
4880
|
+
</Bubble>
|
|
4881
|
+
</Message>
|
|
4882
|
+
<Message>
|
|
4883
|
+
<Bubble>
|
|
4884
|
+
Three deploys. Search ranking is fixed, webhook keys are
|
|
4885
|
+
rotated, and usage-based billing is building now.
|
|
4886
|
+
</Bubble>
|
|
4887
|
+
</Message>
|
|
4888
|
+
<ChatComposer placeholder="Ask about a deploy" />
|
|
3985
4889
|
</CardContent>
|
|
3986
4890
|
</Card>
|
|
3987
|
-
|
|
3988
|
-
</
|
|
4891
|
+
</div>
|
|
4892
|
+
</Browser>
|
|
4893
|
+
<figcaption className="mt-4 text-center text-body-sm text-muted-foreground">
|
|
4894
|
+
Every surface in this preview is a Sable component, styled by the active theme.
|
|
4895
|
+
</figcaption>
|
|
4896
|
+
</figure>
|
|
4897
|
+
</Region>
|
|
4898
|
+
|
|
4899
|
+
<section aria-labelledby="landing-features" className="border-y border-border bg-muted/40 py-20 sm:py-28">
|
|
4900
|
+
<Region max="wide">
|
|
4901
|
+
<Stack gap={10}>
|
|
4902
|
+
<Stack gap={3} className="max-w-2xl">
|
|
4903
|
+
<p className="text-overline text-muted-foreground">Why Sable</p>
|
|
4904
|
+
<h2 id="landing-features" className="text-balance text-heading-1">
|
|
4905
|
+
A design system with the rules written down
|
|
4906
|
+
</h2>
|
|
4907
|
+
<p className="text-body-lg text-muted-foreground">
|
|
4908
|
+
The decisions a senior designer would make are encoded once, so every
|
|
4909
|
+
screen your team or your agent builds starts from them.
|
|
4910
|
+
</p>
|
|
4911
|
+
</Stack>
|
|
4912
|
+
<Reveal>
|
|
4913
|
+
<Grid cols={{ base: 1, sm: 2, lg: 3 }} gap={4}>
|
|
4914
|
+
{features.map((feature) => (
|
|
4915
|
+
<Card key={feature.title} render={<article />} className="h-full">
|
|
4916
|
+
<CardContent className="flex flex-col gap-3">
|
|
4917
|
+
<span className="grid size-10 place-items-center rounded-md sable-well">
|
|
4918
|
+
<Icon name={feature.icon} className="text-xl" />
|
|
4919
|
+
</span>
|
|
4920
|
+
<h3 className="pt-2 text-title-lg">{feature.title}</h3>
|
|
4921
|
+
<p className="text-body-sm text-muted-foreground">{feature.body}</p>
|
|
4922
|
+
</CardContent>
|
|
4923
|
+
</Card>
|
|
4924
|
+
))}
|
|
4925
|
+
</Grid>
|
|
4926
|
+
</Reveal>
|
|
4927
|
+
</Stack>
|
|
4928
|
+
</Region>
|
|
4929
|
+
</section>
|
|
4930
|
+
|
|
4931
|
+
<Region as="section" aria-labelledby="landing-setup" max="wide" className="py-20 sm:py-28">
|
|
4932
|
+
<div className="grid items-start gap-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-16">
|
|
4933
|
+
<Stack gap={8}>
|
|
4934
|
+
<Stack gap={3}>
|
|
4935
|
+
<p className="text-overline text-muted-foreground">Setup</p>
|
|
4936
|
+
<h2 id="landing-setup" className="text-balance text-heading-1">
|
|
4937
|
+
From install to first screen in three steps
|
|
4938
|
+
</h2>
|
|
4939
|
+
</Stack>
|
|
4940
|
+
<ol className="flex flex-col gap-6">
|
|
4941
|
+
{steps.map((step, index) => (
|
|
4942
|
+
<li key={step.title} className="grid grid-cols-[auto_1fr] gap-4">
|
|
4943
|
+
<span className="pt-0.5 text-mono-md text-muted-foreground">0{index + 1}</span>
|
|
4944
|
+
<div>
|
|
4945
|
+
<h3 className="text-title-lg">{step.title}</h3>
|
|
4946
|
+
<p className="mt-1 text-body-md text-muted-foreground">{step.body}</p>
|
|
4947
|
+
</div>
|
|
4948
|
+
</li>
|
|
4949
|
+
))}
|
|
4950
|
+
</ol>
|
|
4951
|
+
</Stack>
|
|
4952
|
+
<Reveal className="min-w-0">
|
|
4953
|
+
<Tabs defaultValue="compose">
|
|
4954
|
+
<TabsList>
|
|
4955
|
+
<TabsTrigger value="install">Install</TabsTrigger>
|
|
4956
|
+
<TabsTrigger value="styles">Styles</TabsTrigger>
|
|
4957
|
+
<TabsTrigger value="compose">Compose</TabsTrigger>
|
|
4958
|
+
</TabsList>
|
|
4959
|
+
<TabsPanel value="install" className="pt-4">
|
|
4960
|
+
<CodeBlock prompt="$" code="pnpm add @sableui/react" />
|
|
4961
|
+
</TabsPanel>
|
|
4962
|
+
<TabsPanel value="styles" className="pt-4">
|
|
4963
|
+
<CodeBlock filename="app.css" code={stylesCode} />
|
|
4964
|
+
</TabsPanel>
|
|
4965
|
+
<TabsPanel value="compose" className="pt-4">
|
|
4966
|
+
<CodeBlock filename="deploy-card.tsx" code={composeCode} />
|
|
4967
|
+
</TabsPanel>
|
|
4968
|
+
</Tabs>
|
|
4969
|
+
</Reveal>
|
|
4970
|
+
</div>
|
|
4971
|
+
</Region>
|
|
4972
|
+
|
|
4973
|
+
<section aria-labelledby="landing-agents" className="border-t border-border py-20 sm:py-28">
|
|
4974
|
+
<Region max="wide">
|
|
4975
|
+
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
|
|
4976
|
+
<Stack gap={5}>
|
|
4977
|
+
<Stack gap={3}>
|
|
4978
|
+
<p className="text-overline text-muted-foreground">Built for agents</p>
|
|
4979
|
+
<h2 id="landing-agents" className="text-balance text-heading-1">
|
|
4980
|
+
Your agent reads the same contract you do
|
|
4981
|
+
</h2>
|
|
4982
|
+
<p className="text-body-lg text-muted-foreground">
|
|
4983
|
+
Point Claude Code, Codex, or Cursor at the installed package. The{" "}
|
|
4984
|
+
<Code>sable</Code> CLI answers what to use, how to use it, and whether
|
|
4985
|
+
the result holds up.
|
|
4986
|
+
</p>
|
|
4987
|
+
</Stack>
|
|
4988
|
+
<ul className="sable-list">
|
|
4989
|
+
{agentPoints.map((point) => (
|
|
4990
|
+
<li key={point}>{point}</li>
|
|
4991
|
+
))}
|
|
4992
|
+
</ul>
|
|
4993
|
+
</Stack>
|
|
4994
|
+
<Reveal className="min-w-0">
|
|
4995
|
+
<CodeBlock filename="Terminal" code={terminalCode} highlight={false} />
|
|
4996
|
+
</Reveal>
|
|
4997
|
+
</div>
|
|
4998
|
+
</Region>
|
|
4999
|
+
</section>
|
|
5000
|
+
|
|
5001
|
+
<Region as="section" aria-labelledby="landing-cta" max="wide" className="pb-20 sm:pb-28">
|
|
5002
|
+
<Reveal>
|
|
5003
|
+
<Card>
|
|
5004
|
+
<CardContent>
|
|
5005
|
+
<Stack gap={4} align="center" className="py-8 text-center sm:py-12">
|
|
5006
|
+
<h2 id="landing-cta" className="max-w-2xl text-balance text-heading-1">
|
|
5007
|
+
Build the product, not another component library
|
|
5008
|
+
</h2>
|
|
5009
|
+
<p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">
|
|
5010
|
+
Install Sable, pick a preset, and ship the first screen today.
|
|
5011
|
+
</p>
|
|
5012
|
+
<Row gap={2} justify="center" wrap className="pt-2">
|
|
5013
|
+
<Button size="cta" render={<a href="/docs/getting-started" />}>
|
|
5014
|
+
Get started
|
|
5015
|
+
</Button>
|
|
5016
|
+
<Button size="cta" variant="outline" render={<a href="/github" />}>
|
|
5017
|
+
Star on GitHub
|
|
5018
|
+
</Button>
|
|
5019
|
+
</Row>
|
|
5020
|
+
</Stack>
|
|
5021
|
+
</CardContent>
|
|
5022
|
+
</Card>
|
|
3989
5023
|
</Reveal>
|
|
3990
5024
|
</Region>
|
|
3991
5025
|
</main>
|
|
5026
|
+
|
|
3992
5027
|
<footer className="border-t border-border">
|
|
3993
|
-
<Region className="
|
|
3994
|
-
<
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
5028
|
+
<Region max="wide" className="py-12">
|
|
5029
|
+
<div className="grid gap-10 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
|
|
5030
|
+
<Stack gap={3} className="max-w-xs">
|
|
5031
|
+
<a href="/" className="w-fit text-title-lg motion-interactive hover:text-muted-foreground">
|
|
5032
|
+
Sable
|
|
5033
|
+
</a>
|
|
5034
|
+
<p className="text-body-sm text-muted-foreground">
|
|
5035
|
+
React components, patterns, and tokens for products built with AI agents.
|
|
5036
|
+
</p>
|
|
5037
|
+
</Stack>
|
|
5038
|
+
<nav aria-label="Footer" className="grid grid-cols-2 gap-8 sm:grid-cols-3">
|
|
5039
|
+
{footerGroups.map((group) => (
|
|
5040
|
+
<div key={group.title}>
|
|
5041
|
+
<h2 className="text-label-md text-muted-foreground">{group.title}</h2>
|
|
5042
|
+
<ul className="mt-3 flex flex-col gap-1">
|
|
5043
|
+
{group.links.map((link) => (
|
|
5044
|
+
<li key={link.label}>
|
|
5045
|
+
<a
|
|
5046
|
+
href={link.href}
|
|
5047
|
+
className="flex min-h-9 items-center text-body-sm motion-interactive hover:text-muted-foreground"
|
|
5048
|
+
>
|
|
5049
|
+
{link.label}
|
|
5050
|
+
</a>
|
|
5051
|
+
</li>
|
|
5052
|
+
))}
|
|
5053
|
+
</ul>
|
|
5054
|
+
</div>
|
|
5055
|
+
))}
|
|
5056
|
+
</nav>
|
|
5057
|
+
</div>
|
|
5058
|
+
<div className="mt-12 border-t border-border pt-6">
|
|
5059
|
+
<p className="text-caption text-muted-foreground">© 2026 Sable. Released under the MIT license.</p>
|
|
5060
|
+
</div>
|
|
4003
5061
|
</Region>
|
|
4004
5062
|
</footer>
|
|
4005
5063
|
</div>
|
|
@@ -4011,98 +5069,415 @@ export function LandingPage() {
|
|
|
4011
5069
|
|
|
4012
5070
|
# ListDetailPage
|
|
4013
5071
|
|
|
4014
|
-
|
|
5072
|
+
Searchable list beside the selected record's detail: interactive Entity rows in a CardList (5 of 12 columns) and a sticky detail pane (7) with an editable field, a label/value list, record actions with a destructive confirmation, and recent activity. Below lg it becomes a drill-in.
|
|
4015
5073
|
|
|
4016
|
-
**Intent:** Reach for this when a route lists records AND
|
|
5074
|
+
**Intent:** Reach for this when a route lists records AND works on one at a time (members + member, inbox + message, orders + order). For pure browsing with no selection, a single-column FeedPage or a DashboardPage table is simpler.
|
|
4017
5075
|
|
|
4018
5076
|
## Notes
|
|
4019
5077
|
|
|
4020
|
-
- Region cap is `wide`;
|
|
4021
|
-
-
|
|
4022
|
-
-
|
|
4023
|
-
-
|
|
4024
|
-
- The
|
|
5078
|
+
- Region cap is `wide`; Columns 12 → Col 5 (list) + Col 7 (detail). Below lg the list and detail share one column as a drill-in: selecting a row shows the detail with an `All members` back button (lg:hidden), and the back button returns to the list. The panes swap with `max-lg:hidden`, so desktop always shows both.
|
|
5079
|
+
- Rows are interactive Entity inside one Card + CardList. Selection is `data-selected` on the row painted by `sable-surface-interactive`; `sable-surface` on the list card makes that step 12% ink off the card, so a selected row stays distinct from its muted avatar.
|
|
5080
|
+
- Search filters the list in place (name, email, role). No matches swap the CardList for an EmptyState with a Clear search action; the selected detail stays open.
|
|
5081
|
+
- A pending invite lives in the same list: a mail glyph in the Avatar, an `Invited` Badge, and an invite-specific detail (role on joining, sent, expires, invited by; Resend and Revoke actions).
|
|
5082
|
+
- The role Select edits the record in place; its Field description states what the role can do. The owner's role is read-only and the owner has no remove action.
|
|
5083
|
+
- Read-only facts are a native `<dl className="sable-list-dl sable-list-dl-2">` inside an `@container`, so pairs go two-up only when the pane is wide enough.
|
|
5084
|
+
- Remove / revoke run through DestructiveActionModal; on confirm the next row is selected. Page-level counts (members, pending, seats) live in the PageHeader description and update with the list.
|
|
5085
|
+
- The detail pane is sticky (lg:top-6) so it stays in view while a long list scrolls.
|
|
4025
5086
|
|
|
4026
5087
|
## Accessibility
|
|
4027
5088
|
|
|
4028
|
-
-
|
|
4029
|
-
- The
|
|
5089
|
+
- Each row is a real button (Entity `interactive` + `onSelect`): Tab moves through rows, Enter/Space selects. The selected row carries visually hidden `(selected)` text in its name; list avatars are aria-hidden so initials are not read before the name.
|
|
5090
|
+
- The detail pane is a labelled region (`Member details`) whose title is a level-2 heading.
|
|
5091
|
+
- On a phone, drilling in moves focus to the back button; going back returns focus to the row that was open. After a remove, focus lands on the newly selected row; after Clear search, on the search field.
|
|
4030
5092
|
|
|
4031
5093
|
## Source
|
|
4032
5094
|
|
|
4033
5095
|
```tsx
|
|
4034
|
-
import {
|
|
5096
|
+
import { useState, useEffect } from "react"
|
|
5097
|
+
import { Avatar, Badge, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, EmptyState, Entity, Field, Icon, Page, PageHeader, Row, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, Stack, StatusDot } from "@sableui/react"
|
|
5098
|
+
|
|
5099
|
+
type Role = "owner" | "admin" | "editor" | "viewer"
|
|
5100
|
+
|
|
5101
|
+
const roles: { value: Role; label: string }[] = [
|
|
5102
|
+
{ value: "owner", label: "Owner" },
|
|
5103
|
+
{ value: "admin", label: "Admin" },
|
|
5104
|
+
{ value: "editor", label: "Editor" },
|
|
5105
|
+
{ value: "viewer", label: "Viewer" },
|
|
5106
|
+
]
|
|
5107
|
+
|
|
5108
|
+
const roleLabel: Record<Role, string> = {
|
|
5109
|
+
owner: "Owner",
|
|
5110
|
+
admin: "Admin",
|
|
5111
|
+
editor: "Editor",
|
|
5112
|
+
viewer: "Viewer",
|
|
5113
|
+
}
|
|
4035
5114
|
|
|
4036
|
-
const
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
5115
|
+
const roleSummary: Record<Role, string> = {
|
|
5116
|
+
owner: "Full access, including payouts and billing. Transfer ownership in studio settings.",
|
|
5117
|
+
admin: "Manages members, editions and payouts.",
|
|
5118
|
+
editor: "Drafts, prices and releases editions.",
|
|
5119
|
+
viewer: "Sees editions and sales, but can't change them.",
|
|
5120
|
+
}
|
|
5121
|
+
|
|
5122
|
+
type Activity = { id: string; icon: string; text: string; when: string }
|
|
5123
|
+
|
|
5124
|
+
type Member = {
|
|
5125
|
+
id: string
|
|
5126
|
+
name: string
|
|
5127
|
+
email: string
|
|
5128
|
+
initials: string
|
|
5129
|
+
role: Role
|
|
5130
|
+
pending: boolean
|
|
5131
|
+
handle?: string
|
|
5132
|
+
joined?: string
|
|
5133
|
+
lastActive?: string
|
|
5134
|
+
editions?: number
|
|
5135
|
+
invitedBy?: string
|
|
5136
|
+
sent?: string
|
|
5137
|
+
expires?: string
|
|
5138
|
+
resent?: boolean
|
|
5139
|
+
activity: Activity[]
|
|
5140
|
+
}
|
|
5141
|
+
|
|
5142
|
+
const seats = 10
|
|
5143
|
+
|
|
5144
|
+
const initialMembers: Member[] = [
|
|
5145
|
+
{
|
|
5146
|
+
id: "u_01", name: "Ada Lovelace", handle: "@ada", email: "ada@atelier.art", initials: "AL", role: "owner", pending: false,
|
|
5147
|
+
joined: "Mar 4, 2024", lastActive: "Online now", editions: 12,
|
|
5148
|
+
activity: [
|
|
5149
|
+
{ id: "a1", icon: "gem", text: "Released Analytical Engine No. 3 — edition of 50", when: "2 hours ago" },
|
|
5150
|
+
{ id: "a2", icon: "mail", text: "Invited louise@bourgeois.studio as Editor", when: "2 days ago" },
|
|
5151
|
+
],
|
|
5152
|
+
},
|
|
5153
|
+
{
|
|
5154
|
+
id: "u_02", name: "Grace Hopper", handle: "@grace", email: "grace@atelier.art", initials: "GH", role: "admin", pending: false,
|
|
5155
|
+
joined: "Mar 9, 2024", lastActive: "20 minutes ago", editions: 4,
|
|
5156
|
+
activity: [
|
|
5157
|
+
{ id: "a1", icon: "user", text: "Changed Frida Kahlo's role to Viewer", when: "Yesterday" },
|
|
5158
|
+
{ id: "a2", icon: "edit", text: "Updated pricing on First Compiler — edition of 10", when: "3 days ago" },
|
|
5159
|
+
{ id: "a3", icon: "gem", text: "Released First Compiler — edition of 10", when: "Sep 28" },
|
|
5160
|
+
],
|
|
5161
|
+
},
|
|
5162
|
+
{
|
|
5163
|
+
id: "u_03", name: "Mary Shelley", handle: "@mary", email: "mary@atelier.art", initials: "MS", role: "editor", pending: false,
|
|
5164
|
+
joined: "Jun 18, 2024", lastActive: "3 hours ago", editions: 7,
|
|
5165
|
+
activity: [
|
|
5166
|
+
{ id: "a1", icon: "calendar", text: "Scheduled Frankenstein Studies for Oct 14", when: "3 hours ago" },
|
|
5167
|
+
{ id: "a2", icon: "gem", text: "Released The Last Man — edition of 25", when: "Sep 21" },
|
|
5168
|
+
],
|
|
5169
|
+
},
|
|
5170
|
+
{
|
|
5171
|
+
id: "u_04", name: "Nina Simone", handle: "@nina", email: "nina@atelier.art", initials: "NS", role: "editor", pending: false,
|
|
5172
|
+
joined: "Jan 12, 2025", lastActive: "Yesterday", editions: 3,
|
|
5173
|
+
activity: [
|
|
5174
|
+
{ id: "a1", icon: "upload", text: "Uploaded artwork for Little Girl Blue", when: "Yesterday" },
|
|
5175
|
+
{ id: "a2", icon: "gem", text: "Released Feeling Good — edition of 100", when: "Aug 30" },
|
|
5176
|
+
],
|
|
5177
|
+
},
|
|
5178
|
+
{
|
|
5179
|
+
id: "u_05", name: "Frida Kahlo", handle: "@frida", email: "frida@atelier.art", initials: "FK", role: "viewer", pending: false,
|
|
5180
|
+
joined: "Jul 2, 2025", lastActive: "4 days ago", editions: 0,
|
|
5181
|
+
activity: [{ id: "a1", icon: "comment", text: "Commented on the Las Dos Fridas draft", when: "4 days ago" }],
|
|
5182
|
+
},
|
|
5183
|
+
{
|
|
5184
|
+
id: "i_01", name: "louise@bourgeois.studio", email: "louise@bourgeois.studio", initials: "", role: "editor", pending: true,
|
|
5185
|
+
invitedBy: "Ada Lovelace", sent: "2 days ago", expires: "In 5 days",
|
|
5186
|
+
activity: [],
|
|
5187
|
+
},
|
|
4043
5188
|
]
|
|
4044
5189
|
|
|
4045
5190
|
export function ListDetailPage() {
|
|
5191
|
+
const [members, setMembers] = useState(initialMembers)
|
|
5192
|
+
const [selectedId, setSelectedId] = useState<string | null>("u_02")
|
|
5193
|
+
const [query, setQuery] = useState("")
|
|
5194
|
+
// Below lg the list and the detail share one column: selecting drills in.
|
|
5195
|
+
const [showDetail, setShowDetail] = useState(false)
|
|
5196
|
+
const [confirming, setConfirming] = useState(false)
|
|
5197
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
5198
|
+
const backRef = useRef<HTMLButtonElement>(null)
|
|
5199
|
+
const searchRef = useRef<HTMLInputElement>(null)
|
|
5200
|
+
|
|
5201
|
+
const needle = query.trim().toLowerCase()
|
|
5202
|
+
const visible = members.filter((m) =>
|
|
5203
|
+
[m.name, m.email, m.handle ?? "", roleLabel[m.role]].some((v) => v.toLowerCase().includes(needle)),
|
|
5204
|
+
)
|
|
5205
|
+
const selected = members.find((m) => m.id === selectedId) ?? null
|
|
5206
|
+
const active = members.filter((m) => !m.pending).length
|
|
5207
|
+
const pending = members.length - active
|
|
5208
|
+
|
|
5209
|
+
// Drilling in on a phone moves focus to the detail's back control; the
|
|
5210
|
+
// control is display:none at lg, so desktop focus stays on the list.
|
|
5211
|
+
useEffect(() => {
|
|
5212
|
+
if (showDetail && backRef.current?.offsetParent) backRef.current.focus()
|
|
5213
|
+
}, [showDetail, selectedId])
|
|
5214
|
+
|
|
5215
|
+
function select(id: string) {
|
|
5216
|
+
setSelectedId(id)
|
|
5217
|
+
setShowDetail(true)
|
|
5218
|
+
}
|
|
5219
|
+
|
|
5220
|
+
function focusRow(id: string | undefined) {
|
|
5221
|
+
requestAnimationFrame(() => {
|
|
5222
|
+
listRef.current?.querySelector<HTMLButtonElement>(`[data-member-id="${id}"] button`)?.focus()
|
|
5223
|
+
})
|
|
5224
|
+
}
|
|
5225
|
+
|
|
5226
|
+
function backToList() {
|
|
5227
|
+
setShowDetail(false)
|
|
5228
|
+
focusRow(selectedId ?? undefined)
|
|
5229
|
+
}
|
|
5230
|
+
|
|
5231
|
+
function setRole(id: string, role: Role) {
|
|
5232
|
+
setMembers((list) => list.map((m) => (m.id === id ? { ...m, role } : m)))
|
|
5233
|
+
}
|
|
5234
|
+
|
|
5235
|
+
function resend(id: string) {
|
|
5236
|
+
setMembers((list) =>
|
|
5237
|
+
list.map((m) => (m.id === id ? { ...m, sent: "Just now", expires: "In 7 days", resent: true } : m)),
|
|
5238
|
+
)
|
|
5239
|
+
}
|
|
5240
|
+
|
|
5241
|
+
function remove(id: string) {
|
|
5242
|
+
const index = members.findIndex((m) => m.id === id)
|
|
5243
|
+
const rest = members.filter((m) => m.id !== id)
|
|
5244
|
+
setMembers(rest)
|
|
5245
|
+
const next = rest[Math.min(index, rest.length - 1)]?.id
|
|
5246
|
+
setSelectedId(next ?? null)
|
|
5247
|
+
setShowDetail(false)
|
|
5248
|
+
// The control that opened the confirmation is gone; land on the next row.
|
|
5249
|
+
focusRow(next)
|
|
5250
|
+
}
|
|
5251
|
+
|
|
4046
5252
|
return (
|
|
4047
5253
|
<Page as="main" max="wide" gap={4} className="py-6">
|
|
4048
5254
|
<PageHeader
|
|
4049
5255
|
title="Members"
|
|
4050
|
-
description="
|
|
4051
|
-
actions={<Button>Invite member</Button>}
|
|
5256
|
+
description={`${active} members · ${pending} pending ${pending === 1 ? "invite" : "invites"} · ${members.length} of ${seats} seats used`}
|
|
5257
|
+
actions={<Button iconStart={<Icon name="plus" />}>Invite member</Button>}
|
|
4052
5258
|
/>
|
|
4053
5259
|
<Columns count={{ base: 1, lg: 12 }}>
|
|
4054
|
-
<Col span={{ base: 1, lg:
|
|
5260
|
+
<Col span={{ base: 1, lg: 5 }} className={showDetail ? "max-lg:hidden" : undefined}>
|
|
4055
5261
|
<Stack gap={3}>
|
|
4056
|
-
<SearchInput
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
)
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
5262
|
+
<SearchInput
|
|
5263
|
+
ref={searchRef}
|
|
5264
|
+
value={query}
|
|
5265
|
+
onValueChange={setQuery}
|
|
5266
|
+
placeholder="Search name, email or role"
|
|
5267
|
+
aria-label="Search members"
|
|
5268
|
+
/>
|
|
5269
|
+
{/* sable-surface derives the rows' selected step from the card (12% ink),
|
|
5270
|
+
so a selected row stays distinct from its muted avatar. */}
|
|
5271
|
+
<Card ref={listRef} className="sable-surface">
|
|
5272
|
+
{visible.length > 0 ? (
|
|
5273
|
+
<CardList aria-label="Members">
|
|
5274
|
+
{visible.map((m) => {
|
|
5275
|
+
const isSelected = m.id === selectedId
|
|
5276
|
+
return (
|
|
5277
|
+
<Entity
|
|
5278
|
+
key={m.id}
|
|
5279
|
+
data-member-id={m.id}
|
|
5280
|
+
data-selected={isSelected ? "" : undefined}
|
|
5281
|
+
// No selected variant on Entity yet: the relative-surface recipe paints
|
|
5282
|
+
// data-selected one step off the card.
|
|
5283
|
+
className="sable-surface-interactive"
|
|
5284
|
+
interactive
|
|
5285
|
+
onSelect={() => select(m.id)}
|
|
5286
|
+
leading={
|
|
5287
|
+
<Avatar
|
|
5288
|
+
fallback={m.pending ? <Icon name="mail" className="size-5" /> : m.initials}
|
|
5289
|
+
status={m.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
|
|
5290
|
+
// The row's title already names it: keep the initials out of the row's name.
|
|
5291
|
+
aria-hidden
|
|
5292
|
+
/>
|
|
5293
|
+
}
|
|
5294
|
+
title={
|
|
5295
|
+
<>
|
|
5296
|
+
{m.name}
|
|
5297
|
+
{isSelected ? <span className="sr-only"> (selected)</span> : null}
|
|
5298
|
+
</>
|
|
5299
|
+
}
|
|
5300
|
+
subtitle={
|
|
5301
|
+
m.pending
|
|
5302
|
+
? `${roleLabel[m.role]} · invite sent ${m.sent?.toLowerCase()}`
|
|
5303
|
+
: `${roleLabel[m.role]} · ${m.handle}`
|
|
5304
|
+
}
|
|
5305
|
+
action={m.pending ? <Badge tone="info">Invited</Badge> : undefined}
|
|
5306
|
+
/>
|
|
5307
|
+
)
|
|
5308
|
+
})}
|
|
5309
|
+
</CardList>
|
|
5310
|
+
) : (
|
|
5311
|
+
<CardContent>
|
|
5312
|
+
<EmptyState
|
|
5313
|
+
icon={<Icon name="search" />}
|
|
5314
|
+
title="No members found"
|
|
5315
|
+
description={`Nobody matches “${query.trim()}”. Try a name, email or role.`}
|
|
5316
|
+
action={
|
|
5317
|
+
<Button variant="secondary" size="sm" onClick={() => {
|
|
5318
|
+
setQuery("")
|
|
5319
|
+
searchRef.current?.focus()
|
|
5320
|
+
}}>
|
|
5321
|
+
Clear search
|
|
5322
|
+
</Button>
|
|
5323
|
+
}
|
|
5324
|
+
/>
|
|
5325
|
+
</CardContent>
|
|
5326
|
+
)}
|
|
4084
5327
|
</Card>
|
|
4085
5328
|
</Stack>
|
|
4086
5329
|
</Col>
|
|
4087
|
-
<Col span={{ base: 1, lg:
|
|
4088
|
-
<
|
|
4089
|
-
<
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
<
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
5330
|
+
<Col span={{ base: 1, lg: 7 }} className={showDetail ? undefined : "max-lg:hidden"}>
|
|
5331
|
+
<Stack role="region" aria-label="Member details" gap={3} className="lg:sticky lg:top-6">
|
|
5332
|
+
<Button
|
|
5333
|
+
ref={backRef}
|
|
5334
|
+
variant="ghost"
|
|
5335
|
+
size="sm"
|
|
5336
|
+
iconStart={<Icon name="chevron-left" />}
|
|
5337
|
+
onClick={backToList}
|
|
5338
|
+
className="self-start lg:hidden"
|
|
5339
|
+
>
|
|
5340
|
+
All members
|
|
5341
|
+
</Button>
|
|
5342
|
+
{selected === null ? (
|
|
5343
|
+
<EmptyState
|
|
5344
|
+
surface="card"
|
|
5345
|
+
icon={<Icon name="user" />}
|
|
5346
|
+
title="No member selected"
|
|
5347
|
+
description="Choose someone from the list to see their role and activity."
|
|
5348
|
+
/>
|
|
5349
|
+
) : (
|
|
5350
|
+
<>
|
|
5351
|
+
<Card>
|
|
5352
|
+
<CardHeader>
|
|
5353
|
+
<Row gap={2}>
|
|
5354
|
+
<Avatar
|
|
5355
|
+
size="lg"
|
|
5356
|
+
fallback={selected.pending ? <Icon name="mail" className="size-6" /> : selected.initials}
|
|
5357
|
+
status={selected.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
|
|
5358
|
+
alt={selected.pending ? "Pending invite" : selected.name}
|
|
5359
|
+
/>
|
|
5360
|
+
<Stack gap={0} className="min-w-0">
|
|
5361
|
+
<CardTitle role="heading" aria-level={2} className="truncate">
|
|
5362
|
+
{selected.name}
|
|
5363
|
+
</CardTitle>
|
|
5364
|
+
<CardDescription className="truncate">
|
|
5365
|
+
{selected.pending ? "Hasn't joined yet" : `${selected.handle} · ${selected.email}`}
|
|
5366
|
+
</CardDescription>
|
|
5367
|
+
</Stack>
|
|
5368
|
+
</Row>
|
|
5369
|
+
{selected.pending ? (
|
|
5370
|
+
<CardAction>
|
|
5371
|
+
<Badge tone="info">Invited</Badge>
|
|
5372
|
+
</CardAction>
|
|
5373
|
+
) : null}
|
|
5374
|
+
</CardHeader>
|
|
5375
|
+
<CardContent>
|
|
5376
|
+
<Stack gap={5}>
|
|
5377
|
+
<Field
|
|
5378
|
+
label={selected.pending ? "Role on joining" : "Role"}
|
|
5379
|
+
description={roleSummary[selected.role]}
|
|
5380
|
+
>
|
|
5381
|
+
<Select
|
|
5382
|
+
items={roles}
|
|
5383
|
+
value={selected.role}
|
|
5384
|
+
onValueChange={(value) => setRole(selected.id, value as Role)}
|
|
5385
|
+
disabled={selected.role === "owner"}
|
|
5386
|
+
>
|
|
5387
|
+
<SelectTrigger className="max-w-72" />
|
|
5388
|
+
<SelectContent>
|
|
5389
|
+
{roles
|
|
5390
|
+
.filter((r) => r.value !== "owner" || selected.role === "owner")
|
|
5391
|
+
.map((r) => (
|
|
5392
|
+
<SelectItem key={r.value} value={r.value}>
|
|
5393
|
+
{r.label}
|
|
5394
|
+
</SelectItem>
|
|
5395
|
+
))}
|
|
5396
|
+
</SelectContent>
|
|
5397
|
+
</Select>
|
|
5398
|
+
</Field>
|
|
5399
|
+
<div className="@container">
|
|
5400
|
+
{selected.pending ? (
|
|
5401
|
+
<dl className="sable-list-dl sable-list-dl-2">
|
|
5402
|
+
<dt>Sent</dt>
|
|
5403
|
+
<dd>{selected.sent}</dd>
|
|
5404
|
+
<dt>Expires</dt>
|
|
5405
|
+
<dd>{selected.expires}</dd>
|
|
5406
|
+
<dt>Invited by</dt>
|
|
5407
|
+
<dd>{selected.invitedBy}</dd>
|
|
5408
|
+
</dl>
|
|
5409
|
+
) : (
|
|
5410
|
+
<dl className="sable-list-dl sable-list-dl-2">
|
|
5411
|
+
<dt>Email</dt>
|
|
5412
|
+
<dd>{selected.email}</dd>
|
|
5413
|
+
<dt>Joined</dt>
|
|
5414
|
+
<dd>{selected.joined}</dd>
|
|
5415
|
+
<dt>Last active</dt>
|
|
5416
|
+
<dd className={selected.lastActive === "Online now" ? "text-success" : undefined}>
|
|
5417
|
+
{selected.lastActive}
|
|
5418
|
+
</dd>
|
|
5419
|
+
<dt>Editions released</dt>
|
|
5420
|
+
<dd>{selected.editions}</dd>
|
|
5421
|
+
</dl>
|
|
5422
|
+
)}
|
|
5423
|
+
</div>
|
|
5424
|
+
</Stack>
|
|
5425
|
+
</CardContent>
|
|
5426
|
+
{selected.role !== "owner" ? (
|
|
5427
|
+
<CardFooter>
|
|
5428
|
+
<Row gap={2} wrap>
|
|
5429
|
+
{selected.pending ? (
|
|
5430
|
+
<Button
|
|
5431
|
+
variant="secondary"
|
|
5432
|
+
size="sm"
|
|
5433
|
+
iconStart={<Icon name={selected.resent ? "check" : "send"} />}
|
|
5434
|
+
disabled={selected.resent}
|
|
5435
|
+
onClick={() => resend(selected.id)}
|
|
5436
|
+
>
|
|
5437
|
+
{selected.resent ? "Invite resent" : "Resend invite"}
|
|
5438
|
+
</Button>
|
|
5439
|
+
) : null}
|
|
5440
|
+
<Button variant="outline" size="sm" onClick={() => setConfirming(true)}>
|
|
5441
|
+
{selected.pending ? "Revoke invite" : "Remove from studio"}
|
|
5442
|
+
</Button>
|
|
5443
|
+
</Row>
|
|
5444
|
+
</CardFooter>
|
|
5445
|
+
) : null}
|
|
5446
|
+
</Card>
|
|
5447
|
+
{selected.activity.length > 0 ? (
|
|
5448
|
+
<Card>
|
|
5449
|
+
<CardHeader>
|
|
5450
|
+
<CardTitle role="heading" aria-level={3}>
|
|
5451
|
+
Recent activity
|
|
5452
|
+
</CardTitle>
|
|
5453
|
+
</CardHeader>
|
|
5454
|
+
<CardList>
|
|
5455
|
+
{selected.activity.map((a) => (
|
|
5456
|
+
<Entity
|
|
5457
|
+
key={a.id}
|
|
5458
|
+
leading={<Icon name={a.icon} className="text-muted-foreground" />}
|
|
5459
|
+
title={a.text}
|
|
5460
|
+
subtitle={a.when}
|
|
5461
|
+
/>
|
|
5462
|
+
))}
|
|
5463
|
+
</CardList>
|
|
5464
|
+
</Card>
|
|
5465
|
+
) : null}
|
|
5466
|
+
<DestructiveActionModal
|
|
5467
|
+
open={confirming}
|
|
5468
|
+
onOpenChange={setConfirming}
|
|
5469
|
+
title={selected.pending ? "Revoke this invite?" : `Remove ${selected.name}?`}
|
|
5470
|
+
description={
|
|
5471
|
+
selected.pending
|
|
5472
|
+
? `${selected.email} won't be able to join with the link they were sent.`
|
|
5473
|
+
: "They lose access to the studio right away. Editions they released stay on sale."
|
|
5474
|
+
}
|
|
5475
|
+
confirmLabel={selected.pending ? "Revoke invite" : "Remove member"}
|
|
5476
|
+
onConfirm={() => remove(selected.id)}
|
|
5477
|
+
/>
|
|
5478
|
+
</>
|
|
5479
|
+
)}
|
|
5480
|
+
</Stack>
|
|
4106
5481
|
</Col>
|
|
4107
5482
|
</Columns>
|
|
4108
5483
|
</Page>
|
|
@@ -4114,37 +5489,84 @@ export function ListDetailPage() {
|
|
|
4114
5489
|
|
|
4115
5490
|
# SettingsPage
|
|
4116
5491
|
|
|
4117
|
-
|
|
5492
|
+
Account settings inside the app shell: a sticky SideNav section rail beside a column of section Cards — a Profile form with the page's one explicit save, Card + CardList + Entity rows with Switches that apply instantly, a read-only payouts summary, account rows, and a delete card confirmed through DestructiveActionModal.
|
|
4118
5493
|
|
|
4119
5494
|
**Intent:** Reach for this for account/profile/workspace settings and any 'edit a handful of grouped preferences' route. Not for a single short form (use AuthPage's centered form shape) or bulk record editing (use DashboardPage + DataTable).
|
|
4120
5495
|
|
|
4121
5496
|
## Notes
|
|
4122
5497
|
|
|
4123
|
-
- Region cap is `
|
|
4124
|
-
-
|
|
4125
|
-
-
|
|
4126
|
-
-
|
|
4127
|
-
-
|
|
5498
|
+
- Region cap is `wide`, split on a 12-column track: a 3-column SideNav rail and a 9-column section column. The rail fills the space a lone content-width column would leave empty and gives the page its table of contents.
|
|
5499
|
+
- The rail is `SideNav` (the documented settings sub-nav), sticky, with in-page anchor items; it hides below lg, where the sections simply stack and the shell's BottomNav carries app navigation.
|
|
5500
|
+
- One save model, stated in the copy: drafted text (the Profile form) commits through its single footer save; toggles apply the moment they change, and the Notifications description says so. Never pair a Switch with a Save button.
|
|
5501
|
+
- Settings rows are Card + CardList + Entity with the Switch in `action` — the row owns its title and one-line description; keep descriptions short, because Entity truncates its subtitle.
|
|
5502
|
+
- Each section is a Card whose CardTitle holds an h2, so the page has a real heading outline under PageHeader's h1; Cards carry the anchor id and `scroll-mt-*` so anchor jumps clear the top edge.
|
|
5503
|
+
- Field wires label/description/error a11y automatically; never hand-roll <label> + <input> pairs. Pair fields side by side with Grid cols={{ base: 1, sm: 2 }}.
|
|
5504
|
+
- The destructive action is last and isolated in its own Card, and it never acts directly: it opens DestructiveActionModal with type-to-confirm on the handle.
|
|
4128
5505
|
- Lives in the same AppShell as DashboardPage: Sidebar from lg up, AppHeader + BottomNav below it, with Settings marked active in both.
|
|
4129
5506
|
|
|
4130
5507
|
## Accessibility
|
|
4131
5508
|
|
|
4132
5509
|
- AppShell provides the skip link and <main id="main-content"> landmark; the active SidebarItem and BottomNavItem carry aria-current.
|
|
4133
|
-
-
|
|
5510
|
+
- The section rail is a labelled <nav> ("Settings sections") distinct from the primary navigation; its active item carries aria-current.
|
|
5511
|
+
- Every section has an h2, so screen-reader heading navigation mirrors the rail.
|
|
5512
|
+
- Each Switch takes its row title as aria-label, since the Entity title is not a <label>.
|
|
4134
5513
|
- Field auto-wires htmlFor/id, aria-describedby (description + error), aria-invalid, and required.
|
|
4135
|
-
-
|
|
5514
|
+
- DestructiveActionModal is an alertdialog: focus opens on Cancel, the backdrop does not dismiss, and the confirm stays disabled until the handle is typed.
|
|
4136
5515
|
|
|
4137
5516
|
## Source
|
|
4138
5517
|
|
|
4139
5518
|
```tsx
|
|
4140
|
-
import {
|
|
5519
|
+
import { useState } from "react"
|
|
5520
|
+
import { AppHeader, AppShell, Avatar, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, DotsMenu, Entity, Field, Form, Grid, Icon, Input, MenuItem, MenuSeparator, NotificationBadge, Page, PageHeader, Row, Select, SelectContent, SelectItem, SelectTrigger, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, SideNav, SideNavItem, Stack, Switch, Textarea } from "@sableui/react"
|
|
5521
|
+
|
|
5522
|
+
const sections = [
|
|
5523
|
+
{ id: "profile", label: "Profile" },
|
|
5524
|
+
{ id: "notifications", label: "Notifications" },
|
|
5525
|
+
{ id: "payouts", label: "Payouts" },
|
|
5526
|
+
{ id: "account", label: "Account" },
|
|
5527
|
+
]
|
|
5528
|
+
|
|
5529
|
+
const timezones = [
|
|
5530
|
+
{ value: "europe-london", label: "London (GMT+1)" },
|
|
5531
|
+
{ value: "europe-paris", label: "Paris (GMT+2)" },
|
|
5532
|
+
{ value: "america-new-york", label: "New York (GMT−4)" },
|
|
5533
|
+
{ value: "america-los-angeles", label: "Los Angeles (GMT−7)" },
|
|
5534
|
+
{ value: "asia-tokyo", label: "Tokyo (GMT+9)" },
|
|
5535
|
+
]
|
|
5536
|
+
|
|
5537
|
+
const notifications = [
|
|
5538
|
+
{ name: "notifyCollects", title: "New collects", description: "When someone collects your work", on: true },
|
|
5539
|
+
{ name: "notifySoldOut", title: "Sold out", description: "When an edition sells its last copy", on: true },
|
|
5540
|
+
{ name: "notifyFollowers", title: "New followers", description: "When a collector follows you", on: false },
|
|
5541
|
+
{ name: "notifyDigest", title: "Weekly digest", description: "Sales and collectors, every Monday", on: true },
|
|
5542
|
+
{ name: "notifyProduct", title: "Product updates", description: "New Atelier features", on: false },
|
|
5543
|
+
]
|
|
5544
|
+
|
|
5545
|
+
const payouts = [
|
|
5546
|
+
{ label: "Next payout", value: "$1,284.60", note: "Friday, Oct 16" },
|
|
5547
|
+
{ label: "Pending", value: "$412.00", note: "Clears in 7 days" },
|
|
5548
|
+
{ label: "Paid this year", value: "$18,920.00", note: "Across 214 collects" },
|
|
5549
|
+
]
|
|
4141
5550
|
|
|
4142
5551
|
export function SettingsPage() {
|
|
5552
|
+
const [activeSection, setActiveSection] = useState("profile")
|
|
5553
|
+
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
5554
|
+
|
|
4143
5555
|
return (
|
|
4144
5556
|
<AppShell
|
|
4145
5557
|
// Sidebar hides below lg, so the same destinations ride in the shell's
|
|
4146
5558
|
// mobile slots — without them the route has no navigation under 1024px.
|
|
4147
|
-
header={
|
|
5559
|
+
header={
|
|
5560
|
+
<AppHeader
|
|
5561
|
+
title={
|
|
5562
|
+
<Row gap={1} align="center">
|
|
5563
|
+
<Icon name="gem" variant="solid" className="text-xl" />
|
|
5564
|
+
<span className="text-title-sm">Atelier</span>
|
|
5565
|
+
</Row>
|
|
5566
|
+
}
|
|
5567
|
+
actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
|
|
5568
|
+
/>
|
|
5569
|
+
}
|
|
4148
5570
|
bottomNav={
|
|
4149
5571
|
<BottomNav aria-label="Primary">
|
|
4150
5572
|
<BottomNavItem icon={<Icon name="grid" />} label="Overview" showLabel render={<a href="/" />} />
|
|
@@ -4157,78 +5579,243 @@ export function SettingsPage() {
|
|
|
4157
5579
|
sidebar={
|
|
4158
5580
|
<Sidebar>
|
|
4159
5581
|
<SidebarHeader>
|
|
4160
|
-
<
|
|
5582
|
+
<Row gap={1} align="center" className="px-3">
|
|
5583
|
+
<Icon name="gem" variant="solid" className="text-xl" />
|
|
5584
|
+
<span className="text-title-sm">Atelier</span>
|
|
5585
|
+
</Row>
|
|
4161
5586
|
</SidebarHeader>
|
|
4162
5587
|
<SidebarNav>
|
|
4163
5588
|
<SidebarItem icon={<Icon name="grid" />} label="Overview" render={<a href="/" />} />
|
|
4164
5589
|
<SidebarItem icon={<Icon name="image-stack" />} label="Editions" render={<a href="/editions" />} />
|
|
4165
5590
|
<SidebarItem icon={<Icon name="user" />} label="Collectors" render={<a href="/collectors" />} />
|
|
4166
|
-
<SidebarItem
|
|
5591
|
+
<SidebarItem
|
|
5592
|
+
icon={<Icon name="bell" />}
|
|
5593
|
+
label="Activity"
|
|
5594
|
+
badge={<NotificationBadge count={4} tone="neutral" />}
|
|
5595
|
+
render={<a href="/activity" />}
|
|
5596
|
+
/>
|
|
4167
5597
|
<SidebarItem icon={<Icon name="gear" />} label="Settings" active render={<a href="/settings" />} />
|
|
4168
5598
|
</SidebarNav>
|
|
5599
|
+
{/* Entity and DotsMenu ink with page tokens; a theme can paint the rail
|
|
5600
|
+
dark on a light page, so this subtree reads the rail's ink instead. */}
|
|
5601
|
+
<SidebarFooter className="[--foreground:var(--sidebar-foreground)]">
|
|
5602
|
+
<Entity
|
|
5603
|
+
className="[--entity-py:0]"
|
|
5604
|
+
leading={<Avatar size="sm" fallback="AL" alt="" />}
|
|
5605
|
+
title="Ada Lovelace"
|
|
5606
|
+
action={
|
|
5607
|
+
<DotsMenu label="Account" size="icon-sm">
|
|
5608
|
+
<MenuItem render={<a href="/@ada" />}>View profile</MenuItem>
|
|
5609
|
+
<MenuItem render={<a href="/settings" />}>Settings</MenuItem>
|
|
5610
|
+
<MenuSeparator />
|
|
5611
|
+
<MenuItem>Sign out</MenuItem>
|
|
5612
|
+
</DotsMenu>
|
|
5613
|
+
}
|
|
5614
|
+
/>
|
|
5615
|
+
</SidebarFooter>
|
|
4169
5616
|
</Sidebar>
|
|
4170
5617
|
}
|
|
4171
5618
|
>
|
|
4172
5619
|
{/* No `as="main"`: AppShell already renders <main id="main-content">. */}
|
|
4173
|
-
<Page max="
|
|
5620
|
+
<Page max="wide" gap={4} className="py-6">
|
|
4174
5621
|
<PageHeader
|
|
4175
5622
|
title="Settings"
|
|
4176
|
-
description="
|
|
5623
|
+
description="Manage your profile, notifications, and payouts."
|
|
4177
5624
|
/>
|
|
4178
|
-
<
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
<
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
5625
|
+
<Columns gap={4}>
|
|
5626
|
+
{/* The section rail is a desktop aid; on mobile the sections simply stack. */}
|
|
5627
|
+
<Col span={{ base: 12, lg: 3 }} className="hidden lg:block">
|
|
5628
|
+
<SideNav aria-label="Settings sections" className="sticky top-6">
|
|
5629
|
+
{sections.map((section) => (
|
|
5630
|
+
<SideNavItem
|
|
5631
|
+
key={section.id}
|
|
5632
|
+
label={section.label}
|
|
5633
|
+
active={activeSection === section.id}
|
|
5634
|
+
onClick={() => setActiveSection(section.id)}
|
|
5635
|
+
render={<a href={`#${section.id}`} />}
|
|
5636
|
+
/>
|
|
5637
|
+
))}
|
|
5638
|
+
</SideNav>
|
|
5639
|
+
</Col>
|
|
5640
|
+
<Col span={{ base: 12, lg: 9 }}>
|
|
5641
|
+
<Stack gap={3}>
|
|
5642
|
+
<Card id="profile" className="scroll-mt-6">
|
|
5643
|
+
<CardHeader>
|
|
5644
|
+
<CardTitle>
|
|
5645
|
+
<h2>Profile</h2>
|
|
5646
|
+
</CardTitle>
|
|
5647
|
+
<CardDescription>How collectors see you on Atelier.</CardDescription>
|
|
5648
|
+
</CardHeader>
|
|
5649
|
+
{/* The one explicit save on the page: profile text is drafted, then
|
|
5650
|
+
committed. Toggles below apply the moment they change. */}
|
|
5651
|
+
<Form onSubmit={(event) => event.preventDefault()} className="gap-0">
|
|
5652
|
+
<CardContent>
|
|
5653
|
+
<Stack gap={3}>
|
|
5654
|
+
<Row gap={2}>
|
|
5655
|
+
<Avatar size="lg" fallback="AL" alt="Ada Lovelace" />
|
|
5656
|
+
<Stack gap={0.5} align="start">
|
|
5657
|
+
<Row gap={1}>
|
|
5658
|
+
<Button type="button" variant="outline" size="sm">
|
|
5659
|
+
Upload photo
|
|
5660
|
+
</Button>
|
|
5661
|
+
<Button type="button" variant="ghost" size="sm">
|
|
5662
|
+
Remove
|
|
5663
|
+
</Button>
|
|
5664
|
+
</Row>
|
|
5665
|
+
<p className="text-caption text-muted-foreground">
|
|
5666
|
+
Square PNG or JPG, at least 400 px.
|
|
5667
|
+
</p>
|
|
5668
|
+
</Stack>
|
|
5669
|
+
</Row>
|
|
5670
|
+
<Grid cols={{ base: 1, sm: 2 }} gap={3}>
|
|
5671
|
+
<Field label="Display name" name="displayName" required>
|
|
5672
|
+
<Input defaultValue="Ada Lovelace" autoComplete="name" />
|
|
5673
|
+
</Field>
|
|
5674
|
+
<Field label="Handle" name="handle" description="atelier.com/@ada" required>
|
|
5675
|
+
<Input defaultValue="ada" autoComplete="username" spellCheck={false} />
|
|
5676
|
+
</Field>
|
|
5677
|
+
</Grid>
|
|
5678
|
+
<Field label="Bio" name="bio" description="Shown on your profile and edition pages.">
|
|
5679
|
+
<Textarea
|
|
5680
|
+
rows={3}
|
|
5681
|
+
defaultValue="Generative artist working in plotted ink and code. New editions of 25 to 100 every month."
|
|
5682
|
+
/>
|
|
5683
|
+
</Field>
|
|
5684
|
+
<Grid cols={{ base: 1, sm: 2 }} gap={3}>
|
|
5685
|
+
<Field label="Timezone" name="timezone" description="Release times use this zone.">
|
|
5686
|
+
<Select defaultValue="europe-london" items={timezones}>
|
|
5687
|
+
<SelectTrigger />
|
|
5688
|
+
<SelectContent>
|
|
5689
|
+
{timezones.map((zone) => (
|
|
5690
|
+
<SelectItem key={zone.value} value={zone.value}>
|
|
5691
|
+
{zone.label}
|
|
5692
|
+
</SelectItem>
|
|
5693
|
+
))}
|
|
5694
|
+
</SelectContent>
|
|
5695
|
+
</Select>
|
|
5696
|
+
</Field>
|
|
5697
|
+
</Grid>
|
|
5698
|
+
</Stack>
|
|
5699
|
+
</CardContent>
|
|
5700
|
+
<CardFooter className="justify-end">
|
|
5701
|
+
<Button type="submit">Save profile</Button>
|
|
5702
|
+
</CardFooter>
|
|
5703
|
+
</Form>
|
|
5704
|
+
</Card>
|
|
5705
|
+
|
|
5706
|
+
<Card id="notifications" className="scroll-mt-6">
|
|
5707
|
+
<CardHeader>
|
|
5708
|
+
<CardTitle>
|
|
5709
|
+
<h2>Notifications</h2>
|
|
5710
|
+
</CardTitle>
|
|
5711
|
+
<CardDescription>
|
|
5712
|
+
Email updates about your editions. Changes save automatically.
|
|
5713
|
+
</CardDescription>
|
|
5714
|
+
</CardHeader>
|
|
5715
|
+
<CardList>
|
|
5716
|
+
{notifications.map((item) => (
|
|
5717
|
+
<Entity
|
|
5718
|
+
key={item.name}
|
|
5719
|
+
title={item.title}
|
|
5720
|
+
subtitle={item.description}
|
|
5721
|
+
action={
|
|
5722
|
+
<Switch
|
|
5723
|
+
name={item.name}
|
|
5724
|
+
defaultChecked={item.on}
|
|
5725
|
+
aria-label={item.title}
|
|
5726
|
+
/>
|
|
5727
|
+
}
|
|
5728
|
+
/>
|
|
5729
|
+
))}
|
|
5730
|
+
</CardList>
|
|
5731
|
+
</Card>
|
|
5732
|
+
|
|
5733
|
+
<Card id="payouts" className="scroll-mt-6">
|
|
5734
|
+
<CardHeader>
|
|
5735
|
+
<CardTitle>
|
|
5736
|
+
<h2>Payouts</h2>
|
|
5737
|
+
</CardTitle>
|
|
5738
|
+
<CardDescription>Paid in USD every Friday to Chase checking ••4821.</CardDescription>
|
|
5739
|
+
<CardAction>
|
|
5740
|
+
<Button variant="outline" size="sm" render={<a href="/settings/payouts" />}>
|
|
5741
|
+
Manage payouts
|
|
5742
|
+
</Button>
|
|
5743
|
+
</CardAction>
|
|
5744
|
+
</CardHeader>
|
|
5745
|
+
<CardContent>
|
|
5746
|
+
<Grid cols={{ base: 1, sm: 3 }} gap={3}>
|
|
5747
|
+
{payouts.map((stat) => (
|
|
5748
|
+
<Stack key={stat.label} gap={0.5}>
|
|
5749
|
+
<p className="text-body-sm text-muted-foreground">{stat.label}</p>
|
|
5750
|
+
<p className="text-mono-lg text-foreground">{stat.value}</p>
|
|
5751
|
+
<p className="text-caption text-muted-foreground">{stat.note}</p>
|
|
5752
|
+
</Stack>
|
|
5753
|
+
))}
|
|
5754
|
+
</Grid>
|
|
5755
|
+
</CardContent>
|
|
5756
|
+
</Card>
|
|
5757
|
+
|
|
5758
|
+
<Card id="account" className="scroll-mt-6">
|
|
5759
|
+
<CardHeader>
|
|
5760
|
+
<CardTitle>
|
|
5761
|
+
<h2>Account</h2>
|
|
5762
|
+
</CardTitle>
|
|
5763
|
+
<CardDescription>Sign-in details and your data.</CardDescription>
|
|
5764
|
+
</CardHeader>
|
|
5765
|
+
<CardList>
|
|
5766
|
+
<Entity
|
|
5767
|
+
leading={<Icon name="mail" className="text-lg text-muted-foreground" />}
|
|
5768
|
+
title="Email"
|
|
5769
|
+
subtitle="ada@atelier.art"
|
|
5770
|
+
action={
|
|
5771
|
+
<Button variant="outline" size="sm">
|
|
5772
|
+
Change
|
|
5773
|
+
</Button>
|
|
5774
|
+
}
|
|
5775
|
+
/>
|
|
5776
|
+
<Entity
|
|
5777
|
+
leading={<Icon name="download" className="text-lg text-muted-foreground" />}
|
|
5778
|
+
title="Export data"
|
|
5779
|
+
subtitle="Editions, sales, and collectors"
|
|
5780
|
+
action={
|
|
5781
|
+
<Button variant="outline" size="sm">
|
|
5782
|
+
Export
|
|
5783
|
+
</Button>
|
|
5784
|
+
}
|
|
5785
|
+
/>
|
|
5786
|
+
</CardList>
|
|
5787
|
+
</Card>
|
|
5788
|
+
|
|
5789
|
+
<Card>
|
|
5790
|
+
<CardHeader>
|
|
5791
|
+
<CardTitle>
|
|
5792
|
+
<h2>Delete account</h2>
|
|
5793
|
+
</CardTitle>
|
|
5794
|
+
<CardDescription>
|
|
5795
|
+
Permanently remove your profile, editions, and sales history. Collectors keep
|
|
5796
|
+
the editions they own.
|
|
5797
|
+
</CardDescription>
|
|
5798
|
+
</CardHeader>
|
|
5799
|
+
<CardFooter className="justify-end">
|
|
5800
|
+
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
|
5801
|
+
Delete account
|
|
5802
|
+
</Button>
|
|
5803
|
+
</CardFooter>
|
|
5804
|
+
</Card>
|
|
5805
|
+
</Stack>
|
|
5806
|
+
</Col>
|
|
5807
|
+
</Columns>
|
|
4231
5808
|
</Page>
|
|
5809
|
+
<DestructiveActionModal
|
|
5810
|
+
open={deleteOpen}
|
|
5811
|
+
onOpenChange={setDeleteOpen}
|
|
5812
|
+
title="Delete your account?"
|
|
5813
|
+
description="This permanently removes your profile, 12 editions, and sales history. Pending payouts are still sent. This can't be undone."
|
|
5814
|
+
confirmLabel="Delete account"
|
|
5815
|
+
confirmingLabel="Deleting…"
|
|
5816
|
+
confirmText="ada"
|
|
5817
|
+
onConfirm={() => setDeleteOpen(false)}
|
|
5818
|
+
/>
|
|
4232
5819
|
</AppShell>
|
|
4233
5820
|
)
|
|
4234
5821
|
}
|
|
@@ -5820,7 +7407,7 @@ import { Browser, Region } from "@sableui/react"
|
|
|
5820
7407
|
|
|
5821
7408
|
**Status:** stable
|
|
5822
7409
|
|
|
5823
|
-
The surface a message's content sits on: four treatments, an align-aware tail
|
|
7410
|
+
The surface a message's content sits on: four treatments, an align-aware tail, a readable measure cap, and an optional row of reaction pills.
|
|
5824
7411
|
|
|
5825
7412
|
**Keywords:** chat bubble, message bubble, speech bubble, reactions, conversation, messaging, ai chat
|
|
5826
7413
|
|
|
@@ -5832,7 +7419,7 @@ The tinted (or untinted) surface a turn's content sits on. Kept separate from Me
|
|
|
5832
7419
|
|
|
5833
7420
|
- Do: Use `muted` for the other party and `primary` for the reader's own messages. Two treatments is the whole vocabulary a conversation needs to say who is speaking.
|
|
5834
7421
|
- Do: Reach for `variant="plain"` for long-form assistant output — rendered markdown, code blocks, tables. A tinted box around three paragraphs reads as a quote block rather than as a turn, and `plain` drops the measure cap with the surface.
|
|
5835
|
-
- Do: Pass the same `align` you gave the enclosing Message, so the
|
|
7422
|
+
- Do: Pass the same `align` you gave the enclosing Message, so the tail lands on the speaker's side.
|
|
5836
7423
|
- Do: Set `tail={false}` on the continuation bubbles in a grouped run — the tail marks where a speaker starts, and repeating it on every line makes a block of one person's messages look like four separate turns.
|
|
5837
7424
|
- Do: Let the register size it. Inside `<Density value="compact">` a Bubble sets itself in the workspace prose tier automatically — a docked agent panel needs no per-call-site sizing, and hardcoding one defeats the mechanism.
|
|
5838
7425
|
- Don't: Do not remove the measure cap to fill a wide window. ~42rem is a reading measure; a bubble spanning a 1600px viewport is a paragraph at 140 characters per line.
|
|
@@ -5844,7 +7431,7 @@ The tinted (or untinted) surface a turn's content sits on. Kept separate from Me
|
|
|
5844
7431
|
|
|
5845
7432
|
| Part | Required | Description |
|
|
5846
7433
|
| --- | --- | --- |
|
|
5847
|
-
| Bubble | yes | The surface itself. Holds the variant, the side, and the
|
|
7434
|
+
| Bubble | yes | The surface itself. Holds the variant, the side, and the tail. |
|
|
5848
7435
|
| BubbleReactions | no | The row of reaction pills, just under the bubble at the message column's small gap, so it reads as belonging to that message rather than the next row. |
|
|
5849
7436
|
| BubbleReaction | no | One reaction pill — emoji, count (hidden at 1), and pressed state. |
|
|
5850
7437
|
|
|
@@ -5860,8 +7447,8 @@ The tinted (or untinted) surface a turn's content sits on. Kept separate from Me
|
|
|
5860
7447
|
| Name | Type | Default | Description |
|
|
5861
7448
|
| --- | --- | --- | --- |
|
|
5862
7449
|
| variant | "muted" \| "primary" \| "outline" \| "plain" | "muted" | Surface treatment. `muted` for the other party, `primary` for the reader's own messages, `outline` for a quieter own-side, `plain` for long-form output that should not sit in a box (also drops the measure cap and padding). |
|
|
5863
|
-
| align | "start" \| "end" | "start" | Which side of the conversation the bubble is on —
|
|
5864
|
-
| tail | boolean | true |
|
|
7450
|
+
| align | "start" \| "end" | "start" | Which side of the conversation the bubble is on — puts the tail on the speaker's side. Match it to the enclosing Message. |
|
|
7451
|
+
| tail | boolean | true | Draw the tail: a small ear off the speaker-side top corner, which goes square to meet it. The same 5px in every theme's radius language, flat through fully pilled. The bubble keeps the ear's space either way, so turning it off for the continuation bubbles in a grouped run keeps the run aligned. |
|
|
5865
7452
|
| ...div props | ComponentPropsWithRef<"div"> | — | Spread onto the surface element. |
|
|
5866
7453
|
|
|
5867
7454
|
## Variants
|
|
@@ -6709,7 +8296,7 @@ Use CheckboxCard when an option needs more visual weight, supporting text, or ic
|
|
|
6709
8296
|
| onCheckedChange | (checked: boolean, eventDetails) => void | — | Fires with the next checked state (Base UI passes eventDetails second). Pair with `checked` for a controlled card; omit both to let it manage its own state. |
|
|
6710
8297
|
| title | ReactNode | — | The card's primary label (text-title-sm). |
|
|
6711
8298
|
| description | ReactNode | — | Optional supporting copy beneath the title (text-body-sm, muted). |
|
|
6712
|
-
| icon | ReactNode | — | Optional
|
|
8299
|
+
| icon | ReactNode | — | Optional glyph (a solid <Icon>) in the tone colour. Its presence switches to the two-dimensional card RadioCard uses — icon top-left, checkbox top-right, copy below — so the two cards match in one form. |
|
|
6713
8300
|
| tone | "neutral" \| "standard" \| "collectible" \| "edition" \| "info" \| "warning" | "neutral" | Semantic colour the checked state carries across the border, checkbox box, and icon. |
|
|
6714
8301
|
| value | string | — | Names the card inside a group-owned CheckboxGroup: the card is checked when the group's value array includes it. Also the value submitted with a form. Inside a CheckboxGroup, `name` stands in for `value` when `value` is omitted. |
|
|
6715
8302
|
| checked / onCheckedChange / defaultChecked / disabled | Base UI Checkbox.Root props | — | Standard checkbox state. Without a group-owned value, each card is independent — drive checked/onCheckedChange per item for multi-select. |
|
|
@@ -7459,9 +9046,9 @@ import { Avatar, ContextCard, ContextCardContent, ContextCardTrigger, Entity } f
|
|
|
7459
9046
|
### With stat row
|
|
7460
9047
|
|
|
7461
9048
|
```tsx
|
|
7462
|
-
import { Avatar, ContextCard, ContextCardContent, ContextCardTrigger,
|
|
9049
|
+
import { Avatar, ContextCard, ContextCardContent, ContextCardTrigger, Entity } from "@sableui/react"
|
|
7463
9050
|
|
|
7464
|
-
<ContextCard><ContextCardTrigger render={<a href='/u/nyx' className='text-title-sm underline-offset-2 hover:underline'>@nyx</a>} /><ContextCardContent><Entity className='[--entity-px:0] [--entity-py:0]' leading={<Avatar fallback='NY' size='lg' />} title='Nyx' subtitle='@nyx' /><
|
|
9051
|
+
<ContextCard><ContextCardTrigger render={<a href='/u/nyx' className='text-title-sm underline-offset-2 hover:underline'>@nyx</a>} /><ContextCardContent><Entity className='[--entity-px:0] [--entity-py:0]' leading={<Avatar fallback='NY' size='lg' />} title='Nyx' subtitle='@nyx' /><div className='@container mt-4 border-t border-border pt-3'><dl className='sable-list-dl sable-list-dl-2'><dt>Posts</dt><dd>128</dd><dt>Followers</dt><dd>4.2k</dd></dl></div></ContextCardContent></ContextCard>
|
|
7465
9052
|
```
|
|
7466
9053
|
|
|
7467
9054
|
---
|
|
@@ -7750,105 +9337,6 @@ import { DatePicker } from "@sableui/react"
|
|
|
7750
9337
|
|
|
7751
9338
|
---
|
|
7752
9339
|
|
|
7753
|
-
# Description
|
|
7754
|
-
|
|
7755
|
-
**Status:** stable
|
|
7756
|
-
|
|
7757
|
-
A labeled key/value list (definition list). Compose with DescriptionTerm + DescriptionDetail, or the ergonomic DescriptionItem shorthand. Aligns terms and details into a grid; cols="2" packs pairs two-wide.
|
|
7758
|
-
|
|
7759
|
-
**Keywords:** description list, metadata, details, key value
|
|
7760
|
-
|
|
7761
|
-
## Usage
|
|
7762
|
-
|
|
7763
|
-
Use Description to present label-value details where each term needs a clear corresponding value.
|
|
7764
|
-
|
|
7765
|
-
### Best practices
|
|
7766
|
-
|
|
7767
|
-
- Do: Use it for object metadata, settings summaries, and read-only record details.
|
|
7768
|
-
- Do: Keep terms brief and values scannable so the list works as reference material.
|
|
7769
|
-
- Don't: Do not use Description for editable forms; pair Label and form controls instead.
|
|
7770
|
-
- Don't: Do not use it for a single key/value. A definition list of one is a labelled value — use a Label and the value, and keep the list semantics for actual lists.
|
|
7771
|
-
|
|
7772
|
-
### Anatomy
|
|
7773
|
-
|
|
7774
|
-
| Part | Required | Description |
|
|
7775
|
-
| --- | --- | --- |
|
|
7776
|
-
| DescriptionItem | yes | One term and detail pair. |
|
|
7777
|
-
| DescriptionTerm | yes | The label for the value. |
|
|
7778
|
-
| DescriptionDetail | yes | The value or explanatory detail. |
|
|
7779
|
-
|
|
7780
|
-
## Subcomponents
|
|
7781
|
-
|
|
7782
|
-
| Name | Type | Description |
|
|
7783
|
-
| --- | --- | --- |
|
|
7784
|
-
| DescriptionTerm | <dt> props + className | The key. Muted label tier; sits in the first grid column. |
|
|
7785
|
-
| DescriptionDetail | <dd> props + tone?: "default" \| "muted" \| "success" \| "warning" \| "destructive" + className | The value. Body tier on the foreground; sits in the second grid column. tone colors the value to encode a status (e.g. Ended → muted, Active → success), replacing inline conditional color classes; defaults to "default". |
|
|
7786
|
-
| DescriptionItem | { term: ReactNode; detail?: ReactNode; tone?: "default" \| "muted" \| "success" \| "warning" \| "destructive"; children? } | Ergonomic shorthand for one term/detail pair — renders a DescriptionTerm followed by a DescriptionDetail. Pass the value via detail or children; tone (defaults to "default") colors the detail value the same way it does on DescriptionDetail. |
|
|
7787
|
-
|
|
7788
|
-
## Props
|
|
7789
|
-
|
|
7790
|
-
| Name | Type | Default | Description |
|
|
7791
|
-
| --- | --- | --- | --- |
|
|
7792
|
-
| cols | "1" \| "2" | "1" | Column density. One term/detail column, or two pairs per row once the list's container is at least @md (28rem) wide; narrower, it stays one column. A container query, not a viewport breakpoint, so a narrow card on a wide screen stays one column. With cols="2" the <dl> is wrapped in an @container div. |
|
|
7793
|
-
|
|
7794
|
-
## Variants
|
|
7795
|
-
|
|
7796
|
-
- **cols**: 1, 2
|
|
7797
|
-
- **tone**: default, muted, success, warning, destructive
|
|
7798
|
-
|
|
7799
|
-
## Tokens used
|
|
7800
|
-
|
|
7801
|
-
`muted-foreground`, `foreground`, `success`, `tone-warning`, `destructive`
|
|
7802
|
-
|
|
7803
|
-
## Accessibility
|
|
7804
|
-
|
|
7805
|
-
- Renders semantic <dl>/<dt>/<dd> elements so assistive tech announces the term/detail relationship.
|
|
7806
|
-
- tone is a visual emphasis only — keep the value text itself meaningful (e.g. "Ended"), never color alone.
|
|
7807
|
-
|
|
7808
|
-
## Related
|
|
7809
|
-
|
|
7810
|
-
- Components: Fieldset, Entity
|
|
7811
|
-
|
|
7812
|
-
## Examples
|
|
7813
|
-
|
|
7814
|
-
### Metadata list
|
|
7815
|
-
|
|
7816
|
-
```tsx
|
|
7817
|
-
import { Description, DescriptionItem } from "@sableui/react"
|
|
7818
|
-
|
|
7819
|
-
<Description>
|
|
7820
|
-
<DescriptionItem term="Network" detail="Solana" />
|
|
7821
|
-
<DescriptionItem term="Supply" detail="100" />
|
|
7822
|
-
<DescriptionItem term="Royalty" detail="5%" />
|
|
7823
|
-
</Description>
|
|
7824
|
-
```
|
|
7825
|
-
|
|
7826
|
-
### Composed term/detail
|
|
7827
|
-
|
|
7828
|
-
```tsx
|
|
7829
|
-
import { Description, DescriptionDetail, DescriptionTerm } from "@sableui/react"
|
|
7830
|
-
|
|
7831
|
-
<Description>
|
|
7832
|
-
<DescriptionTerm>Network</DescriptionTerm>
|
|
7833
|
-
<DescriptionDetail>Solana</DescriptionDetail>
|
|
7834
|
-
<DescriptionTerm>Contract</DescriptionTerm>
|
|
7835
|
-
<DescriptionDetail className="text-mono-sm">7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU</DescriptionDetail>
|
|
7836
|
-
</Description>
|
|
7837
|
-
```
|
|
7838
|
-
|
|
7839
|
-
### Toned status value
|
|
7840
|
-
|
|
7841
|
-
```tsx
|
|
7842
|
-
import { Description, DescriptionItem } from "@sableui/react"
|
|
7843
|
-
|
|
7844
|
-
<Description>
|
|
7845
|
-
<DescriptionItem term="Edition" detail="Midnight Archive" />
|
|
7846
|
-
<DescriptionItem term="Status" detail={isEnded ? "Ended" : "Active"} tone={isEnded ? "muted" : "success"} />
|
|
7847
|
-
</Description>
|
|
7848
|
-
```
|
|
7849
|
-
|
|
7850
|
-
---
|
|
7851
|
-
|
|
7852
9340
|
# DestructiveActionModal
|
|
7853
9341
|
|
|
7854
9342
|
**Status:** stable
|
|
@@ -7968,7 +9456,7 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
|
|
|
7968
9456
|
| Name | Type | Description |
|
|
7969
9457
|
| --- | --- | --- |
|
|
7970
9458
|
| DialogContent | Base UI Popup props + size?: "default" \| "fullscreen" + showCloseButton?: boolean + scroll?: boolean + className | Backdrop + popup; portals to <body>. Composes DialogOverlay by default. size="fullscreen" is the media viewer: a near-opaque scrim (bg-overlay/95), an edge-to-edge popup that centres its children with no card chrome, motion-fade instead of motion-pop, and a safe-area-aware floating close button (Button variant="floating"); at that size showCloseButton defaults to true and scroll to false, and the media fits itself with max-h-full max-w-full object-contain. At the default size, showCloseButton (defaults to false) renders a built-in close (X) button in the top-end corner, and the header leaves room for it (Icon name="xmark"). The popup caps at the viewport height less a 1rem margin and wraps its children in a scrolling region, so a tall dialog scrolls instead of overflowing a viewport whose own scroll is locked; pass scroll={false} when the dialog owns its internal layout (a pinned header over a scrolling body) and supplies its own min-h-0 scroll region. |
|
|
7971
|
-
| DialogOverlay | Base UI Backdrop props + size?: "default" \| "fullscreen" + className | The styleable scrim/backdrop — bg-overlay/
|
|
9459
|
+
| DialogOverlay | Base UI Backdrop props + size?: "default" \| "fullscreen" + className | The styleable scrim/backdrop — bg-overlay/(--scrim-alpha) by default (50% light, 72% dark, so it dims a near-black page as visibly as a white one), near-opaque bg-overlay/95 at size="fullscreen". Composed into DialogContent (at the matching size) by default; exported for manual styling. |
|
|
7972
9460
|
| DialogTrigger | Base UI Dialog.Trigger props (render) | Element that opens the dialog. Compose via render prop. |
|
|
7973
9461
|
| DialogClose | Base UI Dialog.Close props (render) | Element that dismisses the dialog. Compose via render prop. |
|
|
7974
9462
|
| DialogHeader | ComponentProps<"div"> | Top section holding DialogTitle/DialogDescription. |
|
|
@@ -8129,7 +9617,7 @@ import { DotsMenu, Icon, MenuItem, MenuSeparator } from "@sableui/react"
|
|
|
8129
9617
|
|
|
8130
9618
|
**Status:** stable
|
|
8131
9619
|
|
|
8132
|
-
The centered empty / error block: optional icon
|
|
9620
|
+
The centered empty / error block: optional icon or error code, title, description and an action. tone="destructive" makes it an announced error, size="lg" a page-level state, surface="card" a self-contained card.
|
|
8133
9621
|
|
|
8134
9622
|
**Keywords:** empty, zero state, blank slate, no results, placeholder, onboarding, no data
|
|
8135
9623
|
|
|
@@ -8160,12 +9648,12 @@ Use EmptyState when a content area has no items, no search results, or no first-
|
|
|
8160
9648
|
|
|
8161
9649
|
| Name | Type | Default | Description |
|
|
8162
9650
|
| --- | --- | --- | --- |
|
|
8163
|
-
| icon | ReactNode | — | Optional
|
|
9651
|
+
| icon | ReactNode | — | Optional glyph above the title — an `<Icon />` takes the muted ink at 3xl; an image or illustration renders as given. |
|
|
8164
9652
|
| title | ReactNode | — | The headline. Short and direct. |
|
|
8165
9653
|
| description | ReactNode | — | Optional supporting line beneath the title. |
|
|
8166
9654
|
| action | ReactNode | — | Optional action below the copy — typically a `<Button />`. |
|
|
8167
9655
|
| code | ReactNode | — | A large figure above the title, such as an error code (404). |
|
|
8168
|
-
| tone | "neutral" \| "destructive" | "neutral" | destructive is an error state: a destructive icon
|
|
9656
|
+
| tone | "neutral" \| "destructive" | "neutral" | destructive is an error state: a destructive icon (a warning by default) and role="alert" so it is announced. |
|
|
8169
9657
|
| size | "md" \| "lg" | "md" | lg is page-level: the title is an h2 and the block takes more room. md sits in a panel, list or card. |
|
|
8170
9658
|
| surface | "none" \| "card" | "none" | card draws it as a Card, for an error or empty block standing on its own in a page. |
|
|
8171
9659
|
|
|
@@ -8181,7 +9669,7 @@ Use EmptyState when a content area has no items, no search results, or no first-
|
|
|
8181
9669
|
|
|
8182
9670
|
## Accessibility
|
|
8183
9671
|
|
|
8184
|
-
- Decorative
|
|
9672
|
+
- Decorative icons should not carry meaning the title doesn't already convey.
|
|
8185
9673
|
- Pair with an actionable next step so the zero-state is a path forward, not a dead end.
|
|
8186
9674
|
|
|
8187
9675
|
## Related
|
|
@@ -8289,7 +9777,7 @@ Use Entity to show a compact identity block for a person, organization, file, pr
|
|
|
8289
9777
|
|
|
8290
9778
|
## Related
|
|
8291
9779
|
|
|
8292
|
-
- Components: Avatar, Badge
|
|
9780
|
+
- Components: Avatar, Badge
|
|
8293
9781
|
|
|
8294
9782
|
## Examples
|
|
8295
9783
|
|
|
@@ -8435,7 +9923,7 @@ Use Fieldset to group related controls under a shared label or explanation.
|
|
|
8435
9923
|
|
|
8436
9924
|
## Related
|
|
8437
9925
|
|
|
8438
|
-
- Components: Form, Label
|
|
9926
|
+
- Components: Form, Label
|
|
8439
9927
|
|
|
8440
9928
|
## Examples
|
|
8441
9929
|
|
|
@@ -8992,7 +10480,7 @@ import { Input } from "@sableui/react"
|
|
|
8992
10480
|
|
|
8993
10481
|
**Status:** stable
|
|
8994
10482
|
|
|
8995
|
-
A keyboard key indicator. Renders a
|
|
10483
|
+
A keyboard key indicator. Renders a raised <kbd> key cap sized from the surrounding text, so one Kbd fits body copy, menus and tooltips; KbdGroup sets one cap per key for a combo. `mod` renders the platform modifier (⌘ or Ctrl); `variant="ghost"` is the bare label for menu rows and tooltips.
|
|
8996
10484
|
|
|
8997
10485
|
**Keywords:** keyboard, shortcut, key, kbd
|
|
8998
10486
|
|
|
@@ -9003,8 +10491,10 @@ Use Kbd to display keyboard keys or shortcuts in help text, menus, and docs.
|
|
|
9003
10491
|
### Best practices
|
|
9004
10492
|
|
|
9005
10493
|
- Do: Use it only for literal keyboard input such as Cmd, Enter, or Esc.
|
|
9006
|
-
- Do:
|
|
10494
|
+
- Do: Show a combo as one cap per key inside KbdGroup — `<KbdGroup><Kbd mod /><Kbd>K</Kbd></KbdGroup>` — not several keys typed into one cap.
|
|
10495
|
+
- Do: Use `variant="ghost"` where the shortcut sits on a surface of its own — a menu row's trailing slot, a tooltip — so it reads as a hint, not a second control.
|
|
9007
10496
|
- Don't: Do not use Kbd as a decorative badge or button replacement.
|
|
10497
|
+
- Don't: Do not set a text size on a Kbd. It sizes itself from the text around it.
|
|
9008
10498
|
- Don't: Do not hardcode ⌘ or Ctrl. The modifier differs by platform; use `<Kbd mod />`, which renders ⌘ on macOS/iOS and Ctrl elsewhere, or Windows users read Mac keys.
|
|
9009
10499
|
|
|
9010
10500
|
## Props
|
|
@@ -9012,17 +10502,19 @@ Use Kbd to display keyboard keys or shortcuts in help text, menus, and docs.
|
|
|
9012
10502
|
| Name | Type | Default | Description |
|
|
9013
10503
|
| --- | --- | --- | --- |
|
|
9014
10504
|
| mod | boolean | false | Render the platform's shortcut modifier in place of children: ⌘ on macOS and iOS, Ctrl everywhere else. Server-rendered as Ctrl and corrected on hydration. |
|
|
10505
|
+
| variant | "key" \| "ghost" | "key" | `key`: a raised cap on the surface it sits on, with a heavier bottom edge as the key's lip. `ghost`: the bare label, for menu rows, tooltips and other surfaces that are already a frame. |
|
|
9015
10506
|
| className | string | — | Merge additional classes onto the key cap. |
|
|
9016
10507
|
| ...props | ComponentPropsWithRef<"kbd"> | — | All native <kbd> attributes (children, ref, etc.). |
|
|
9017
10508
|
|
|
9018
10509
|
## Tokens used
|
|
9019
10510
|
|
|
9020
|
-
`border`, `
|
|
10511
|
+
`border`, `border-strong`, `background`, `muted-foreground`
|
|
9021
10512
|
|
|
9022
10513
|
## Accessibility
|
|
9023
10514
|
|
|
9024
10515
|
- Uses the semantic <kbd> element so assistive tech announces keyboard input.
|
|
9025
|
-
-
|
|
10516
|
+
- KbdGroup is itself a <kbd> around the per-key caps — the HTML spelling of one key combination — so the combo is announced as a single piece of input.
|
|
10517
|
+
- Sized in em from the surrounding text, so it scales with the user's text size and never shrinks below 12px.
|
|
9026
10518
|
|
|
9027
10519
|
## Related
|
|
9028
10520
|
|
|
@@ -9041,9 +10533,17 @@ import { Kbd } from "@sableui/react"
|
|
|
9041
10533
|
### Combo
|
|
9042
10534
|
|
|
9043
10535
|
```tsx
|
|
9044
|
-
import { Kbd,
|
|
10536
|
+
import { Kbd, KbdGroup } from "@sableui/react"
|
|
10537
|
+
|
|
10538
|
+
<KbdGroup><Kbd mod /><Kbd>K</Kbd></KbdGroup>
|
|
10539
|
+
```
|
|
10540
|
+
|
|
10541
|
+
### In a menu row
|
|
10542
|
+
|
|
10543
|
+
```tsx
|
|
10544
|
+
import { Kbd, MenuItem } from "@sableui/react"
|
|
9045
10545
|
|
|
9046
|
-
<
|
|
10546
|
+
<MenuItem>Search<KbdGroup className="ms-auto"><Kbd variant="ghost" mod /><Kbd variant="ghost">K</Kbd></KbdGroup></MenuItem>
|
|
9047
10547
|
```
|
|
9048
10548
|
|
|
9049
10549
|
---
|
|
@@ -10764,7 +12264,7 @@ Use Pill for a row of filters or choices a person clicks to narrow or toggle wha
|
|
|
10764
12264
|
| --- | --- | --- | --- |
|
|
10765
12265
|
| selected | boolean | false | Marks the pill as chosen. Solid lifts onto the primary surface; outline takes a primary tint and soft primary edge. Sets aria-pressed. |
|
|
10766
12266
|
| appearance | "solid" \| "outline" | "solid" | solid for a filter row; outline for quiet chips that annotate other content (a reaction under a message, a tag on a card), where a filled chip would compete with what it annotates. |
|
|
10767
|
-
| size | "md" \| "sm" | "md" |
|
|
12267
|
+
| size | "md" \| "sm" | "md" | md sits on the density ladder's xs control height (28px); sm is 24px, for chips under or inside other content. |
|
|
10768
12268
|
| icon | ReactNode | — | Optional leading glyph (e.g. <Icon name="lock" />), rendered before the children with the base gap. |
|
|
10769
12269
|
|
|
10770
12270
|
## Variants
|
|
@@ -12113,7 +13613,7 @@ Use SideNav for vertical navigation inside a settings page, documentation area,
|
|
|
12113
13613
|
- Do: Use aria-label on SideNav when a page contains more than one nav landmark.
|
|
12114
13614
|
- Do: Group related items with SideNavGroup when the rail has more than one conceptual area.
|
|
12115
13615
|
- Do: Use size='lg' and activeVariant='sidebar' when the nav is part of persistent app chrome.
|
|
12116
|
-
- Do:
|
|
13616
|
+
- Do: Rows are rounded-sm, which nests in the usual rounded-lg p-2 frame. In any other rounded, padded frame give each item rounded-concentric-<outer>/<inset> — a same-radius row inset into a frame reads as a second system.
|
|
12117
13617
|
- Don't: Use SideNav for bottom mobile navigation; use BottomNav in AppShell's bottomNav slot.
|
|
12118
13618
|
- Don't: Mix content and sidebar active variants in the same rail unless individual items intentionally override the group.
|
|
12119
13619
|
|
|
@@ -12239,7 +13739,7 @@ Use Skeleton to reserve space and hint at the shape of content while data is loa
|
|
|
12239
13739
|
- **arrival** · Root · _no motion_ — A skeleton stands in for content that has not arrived; fading the placeholder in adds a wait before the wait.
|
|
12240
13740
|
- **response** · Root · _no motion_ — Nothing here is interactive. A skeleton that reacted to the pointer would invite a click on a thing that cannot be clicked.
|
|
12241
13741
|
- **transition** · Root · _no motion_ — The skeleton has one state. Swapping it for real content is the parent's job, and the parent decides whether that swap is animated.
|
|
12242
|
-
- **activity** · Root · `motion-shimmer` — The sweep is the entire point of the component: it is what separates 'still loading' from 'loaded, and empty'. A static grey block is indistinguishable from a broken layout, which is why users reload the page.
|
|
13742
|
+
- **activity** · Root · `motion-shimmer` — The sweep is the entire point of the component: it is what separates 'still loading' from 'loaded, and empty'. A static grey block is indistinguishable from a broken layout, which is why users reload the page. It is one band of light against the viewport, so every skeleton in a loading layout lights in sequence as a single wave rather than each block flashing at its own speed; each pass eases in, glides and settles, then rests off-screen before the next.
|
|
12243
13743
|
|
|
12244
13744
|
## Accessibility
|
|
12245
13745
|
|
|
@@ -13258,7 +14758,7 @@ Use ThemeSelector wherever a person picks a theme. The default `cards` layout is
|
|
|
13258
14758
|
|
|
13259
14759
|
### Best practices
|
|
13260
14760
|
|
|
13261
|
-
- Do: Use layout="list" in a popover, a menu, or a docked panel. Rows sit on the --control-sm ladder and still show each theme's swatch strip, so the choice stays visible rather than described. Rows take `sable-frame-inner`, so host the list in a sable-frame (
|
|
14761
|
+
- Do: Use layout="list" in a popover, a menu, or a docked panel. Rows sit on the --control-sm ladder and still show each theme's swatch strip, so the choice stays visible rather than described. Rows take `sable-frame-inner`, so host the list in a sable-frame (`sable-frame sable-surface sable-surface-popover sable-frame-popover` — PopoverContent is not one; give it that class list, or its p-4 leaves the rows unnested) and their corners nest concentrically.
|
|
13262
14762
|
- Do: Pass mode={useSableTheme().mode} so every preview renders in the colour mode actually in use, rather than in each theme's manifest default.
|
|
13263
14763
|
- Do: Load each theme's fonts in the host app. Every card carries a type specimen in that theme's own face so people can compare typography as well as color. A theme whose fonts are not loaded falls back to system-ui, and its specimen stops telling the truth.
|
|
13264
14764
|
- Don't: Add a viewport breakpoint to force a column count — `className="sm:grid-cols-1"` or similar. `sm:` keys off the VIEWPORT, not this component's container, so on a wide screen it does nothing for a narrow parent. The cards grid already reflows on its own width via auto-fit.
|
|
@@ -14452,7 +15952,7 @@ import { PanelGroup, Workspace, WorkspaceBar, WorkspaceCanvas } from "@sableui/r
|
|
|
14452
15952
|
|
|
14453
15953
|
Which token to reach for, and what it is most often confused with. Generated from `src/themes/token-docs.ts`; the full data is `registry.json → tokens`.
|
|
14454
15954
|
|
|
14455
|
-
### semantic (
|
|
15955
|
+
### semantic (45)
|
|
14456
15956
|
|
|
14457
15957
|
- **`background`** — The page. The furthest-back surface in the page register. *Use for:* The document body, and any surface meant to read as 'the page itself'. Never anything that floats: background is the BOTTOM of the surface stack (background < card < popover in every dark mode), so a floating panel painted with it reads as a hole beneath the cards it hovers over.
|
|
14458
15958
|
- **Not** `popover` for anything floating, `card` for a bounded block on the page. `canvas`. `canvas` is the *work* surface in a tool — the artboard being edited — which is not the page behind the UI. Using `background` for an artboard makes the thing being edited indistinguishable from the chrome around it.
|
|
@@ -14474,8 +15974,10 @@ Which token to reach for, and what it is most often confused with. Generated fro
|
|
|
14474
15974
|
- **Not** `border-strong`. Reach for `border-strong` when the line carries *structure* (a grid rule, a table divider, a section separator) and for `border` when it merely bounds a control.
|
|
14475
15975
|
- **`border-strong`** — A structural divider. Visibly stronger than `border`. *Use for:* Grid lines, table rules, separators between major sections.
|
|
14476
15976
|
- **Not** `border`. If removing the line would make the layout ambiguous, it is structural and belongs here.
|
|
14477
|
-
- **`input`** — The outline of a form control specifically. *Use for:* Text inputs, textareas
|
|
15977
|
+
- **`input`** — The outline of a form control specifically. *Use for:* Text inputs, textareas and selects. Not the edge of a checkbox, radio or switch — that is `control-border`.
|
|
14478
15978
|
- **Not** `border`. They are often the same value, but a theme is entitled to make form controls read differently from other chrome, and only this token gives it the hook.
|
|
15979
|
+
- **`control-border`** — The resting edge of a control whose outline is the whole control. Held to 3:1 (WCAG 1.4.11) on every surface. *Use for:* An unchecked checkbox, an unselected radio, an off switch track, a selection card's check or radio mark — anywhere removing the edge would make the control disappear.
|
|
15980
|
+
- **Not** `input`. A text field is also found by its fill, caret and placeholder, so its outline may stay quiet; a checkbox has nothing else, and at `input`'s strength it measures about 1.4:1.
|
|
14479
15981
|
- **`ring`** — The focus indicator colour. *Use for:* Only for `focus-visible`. Nothing else.
|
|
14480
15982
|
- **Not** `primary`. Focus must remain distinguishable from selection; if the ring is the same colour as a selected state, a keyboard user cannot tell where focus is.
|
|
14481
15983
|
- **`primary`** — The one high-emphasis action colour. *Use for:* The single most important action on a surface — one per view. A submit button, the primary CTA.
|
|
@@ -14803,7 +16305,7 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
|
|
|
14803
16305
|
- **Instead of** `bg-muted`, which is fixed: inside a popover it can sit within a step of the panel in some dark modes. And never a hand-mixed `color-mix(… var(--foreground) …)` or an outline on every well to compensate — that turns a panel into a stack of boxes. For a segmented control itself, use `ToggleGroup`; the well is for what no component draws.
|
|
14804
16306
|
- `<div className="sable-well rounded-md p-1">…</div>`
|
|
14805
16307
|
|
|
14806
|
-
### frame (
|
|
16308
|
+
### frame (7)
|
|
14807
16309
|
|
|
14808
16310
|
- **`sable-frame`** — A bordered, padded, rounded container that computes the radius its children need: `--sable-frame-inner-radius` = radius − padding − border width, floored at `radius-sm`. *Use for:* A container whose children have their own rounded backgrounds (rows, tiles, a nested preview), so the corners nest concentrically and keep doing so when the radius ladder is rescaled. Defaults: `radius-lg`, `--spacing(4)` padding (1rem at the default scale, and it follows the density control), a 1px edge from the surface (`--sable-surface-edge`, else `border`). Size it with `sable-frame-r-*` / `sable-frame-p-*`; children take `sable-frame-inner`.
|
|
14809
16311
|
- **Instead of** `rounded-lg p-4 border` on the parent plus a guessed `rounded-sm` on each child — the guess is concentric only at the default ladder. For a one-off nested corner without a frame, `rounded-concentric-*`.
|
|
@@ -14811,6 +16313,9 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
|
|
|
14811
16313
|
- **`sable-frame-popover`** — The popup frame recipe: `radius-md`, `--spacing(1)` padding, the `border-floating` edge and `shadow-lg`, applied over `sable-frame`. *Use for:* A hand-composed floating list or panel that must match Sable's menus (with `sable-frame sable-surface sable-surface-popover`); rows inside take `sable-frame-inner`.
|
|
14812
16314
|
- **Instead of** Copying a Menu's class string. And use `Menu`, `Select`, `Popover` or `Combobox` first — they own focus and dismissal; this is only for floating chrome no component covers.
|
|
14813
16315
|
- `<div className="sable-frame sable-surface sable-surface-popover sable-frame-popover">…</div>`
|
|
16316
|
+
- **`sable-frame-tool`** — The frame for edge-to-edge tool chrome at the theme's radius, with no padding. It publishes concentric geometry for what's inside: `--panel-inset` (row side padding, grown so rows clear the corner), `--panel-field-radius` (compact fields curve parallel to the frame) and `--panel-edge` (the first and last section's extra top and bottom). PanelSection and the xs field shell read them. Applied over `sable-frame`. *Use for:* Framing PanelSections, ScrubFields, a Tree, a StatusBar or a PanelGroup outside a Workspace (a docs demo, a floating inspector). Add `overflow-hidden` to clip to the corners.
|
|
16317
|
+
- **Instead of** `rounded-lg border overflow-hidden` by hand: the panel keeps its square-column 8px inset and small field radius, so a rounded preset crowds the first label and last field into the curve.
|
|
16318
|
+
- `<div className="sable-frame sable-frame-tool overflow-hidden bg-panel" data-density="compact"><PanelSection title="Layer">…</PanelSection></div>`
|
|
14814
16319
|
- **`sable-frame-borderless`** — Sets a `sable-frame`'s border width to 0 while keeping the inner-radius math correct. *Use for:* A frame that separates by fill or space rather than an edge (a well, a tinted group) but still needs concentric children.
|
|
14815
16320
|
- **Instead of** `border-0` on a `sable-frame`, which removes the border but leaves 1px in the inner-radius calculation.
|
|
14816
16321
|
- `<div className="sable-frame sable-frame-borderless sable-well sable-frame-r-md sable-frame-p-1">…</div>`
|
|
@@ -14826,14 +16331,14 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
|
|
|
14826
16331
|
|
|
14827
16332
|
### geometry (4)
|
|
14828
16333
|
|
|
14829
|
-
- **`rounded-concentric-*`** — A border radius computed as an outer radius minus an inset: `rounded-concentric-<radius>/<spacing>`,
|
|
16334
|
+
- **`rounded-concentric-*`** — A border radius computed as an outer radius minus an inset: `rounded-concentric-<radius>/<spacing>`, floored at `radius-xs` so a deep inset never leaves a 1-2px sliver. `rounded-concentric-md/1` = `radius-md` − `spacing-1`. *Use for:* A one-off rounded element nested inside another — a tab trigger inside a tab list, a thumb inside a track, a row inside a well — where a `sable-frame` would be overkill. The outer radius and the inset are the parent's.
|
|
14830
16335
|
- **Instead of** Picking the next ladder step down by eye (`rounded-sm` inside `rounded-md p-1`). That is concentric only at the default ladder; the utility recomputes from live tokens. Registered in `cn()` as a radius, so it correctly overrides a component's own `rounded-*`.
|
|
14831
16336
|
- `<div className="sable-well rounded-md p-1"><button className="rounded-concentric-md/1 px-2 py-1">…</button></div>`
|
|
14832
16337
|
- **`popup-inset`** — The padding of a popup list, derived from the popup's radius: a third of `radius-md`, at least `--spacing(1)`. A rounder popup gets more room, and the floor is on the spacing scale, so the density control moves it. *Use for:* On a hand-composed rounded-md popup that holds a list of rows. Sable's Menu, Select, Combobox and ContextMenu already use it.
|
|
14833
16338
|
- **Instead of** `p-1`. A fixed inset at a larger radius preset leaves the rows' corners approaching half their height, so every row turns into a pill.
|
|
14834
16339
|
- `<div className="rounded-md bg-popover popup-inset shadow-md">…</div>`
|
|
14835
16340
|
- **`rounded-popup-item`** — The row radius that nests inside a `popup-inset` popup: `radius-md` minus the popup inset, clamped at zero. *Use for:* On every row inside a `popup-inset` container.
|
|
14836
|
-
- **Instead of** `rounded-sm` on menu rows, which matches
|
|
16341
|
+
- **Instead of** `rounded-sm` on menu rows, which matches no ladder exactly (a 12px popup with a 4px inset and 1px border wants 7px rows).
|
|
14837
16342
|
- `<div role="option" className="rounded-popup-item px-2 py-1.5 sable-highlighted:bg-accent">…</div>`
|
|
14838
16343
|
- **`popup-bleed`** — A negative inline margin of exactly one `popup-inset`, so a separator inside a padded popup runs edge to edge at every radius and density. *Use for:* On a hand-composed separator (a 1px rule) inside a `popup-inset` popup. Sable's MenuSeparator, ContextMenuSeparator and SelectSeparator already use it.
|
|
14839
16344
|
- **Instead of** `-mx-1`, which matches the inset only at the default ladder and stops short of both edges once a rounder preset or a roomier density grows it. design-lint flags it (`popup-separator-bleed`).
|
|
@@ -14866,8 +16371,20 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
|
|
|
14866
16371
|
- **Instead of** `scrollbar-quiet` on a vertical list. A vertical region with no bar and no fade tells a reader nothing about how far through they are.
|
|
14867
16372
|
- `<div className="flex gap-3 overflow-x-auto scrollbar-hidden">…</div>`
|
|
14868
16373
|
|
|
14869
|
-
### text (
|
|
14870
|
-
|
|
16374
|
+
### text (12)
|
|
16375
|
+
|
|
16376
|
+
- **`sable-list`** — A bulleted list as typography: body type, a hanging muted marker, and item rhythm. Put it on a native `<ul>`. *Use for:* Running content with bullets: release notes, a feature list, steps that are not ordered.
|
|
16377
|
+
- **Instead of** A stack of `<div>`s with a hand-drawn dot, or an unstyled `<ul>` that inherits the reset (no markers, no indent).
|
|
16378
|
+
- `<ul className="sable-list"><li>Faster sync</li><li>Offline drafts</li></ul>`
|
|
16379
|
+
- **`sable-list-numbered`** — A numbered list as typography: `sable-list` with decimal markers in tabular figures. Put it on a native `<ol>`. *Use for:* Ordered content in running text: setup steps, a ranked list. For a stepped flow with state, use Stepper.
|
|
16380
|
+
- **Instead of** Numbers typed into the text, which neither renumber nor read as a list to assistive tech.
|
|
16381
|
+
- `<ol className="sable-list-numbered"><li>Install</li><li>Import the styles</li></ol>`
|
|
16382
|
+
- **`sable-list-dl`** — Label/value pairs on a native `<dl>`: muted label-tier `<dt>`s and body-tier `<dd>`s on a baseline-aligned two-column grid; long values wrap inside their column. Colour a status value with a `text-*` utility on its `<dd>`. *Use for:* Read-only details: an order summary, a record's metadata, a settings summary, profile stats.
|
|
16383
|
+
- **Instead of** A grid of `<div>`s (screen readers lose the term/value pairing), or Table for a single record. For editable values use Form and Field.
|
|
16384
|
+
- `<dl className="sable-list-dl"><dt>Network</dt><dd>Solana</dd><dt>Status</dt><dd className="text-success">Active</dd></dl>`
|
|
16385
|
+
- **`sable-list-dl-2`** — Two label/value pairs a row once the nearest `@container` ancestor is at least 28rem wide. Add to `sable-list-dl`. *Use for:* A compact metadata block in a wide card or panel. Put `@container` on a parent so it measures the room it actually has.
|
|
16386
|
+
- **Instead of** A viewport breakpoint, which gives a narrow card on a wide screen four columns it cannot fit.
|
|
16387
|
+
- `<div className="@container"><dl className="sable-list-dl sable-list-dl-2">…</dl></div>`
|
|
14871
16388
|
- **`text-overline`** — The eyebrow / overline / kicker tier: small, letter-spaced, uppercase by default (a theme may set sentence case via `--type-overline-transform`). *Use for:* A section eyebrow above a heading, a stat's label, a badge-style category line. Sits ABOVE and categorises.
|
|
14872
16389
|
- **Instead of** `text-label-xs uppercase tracking-wider` assembled by hand, or `text-caption`, which sits BELOW and explains. In tool chrome, panel headers use `text-ui-section` instead.
|
|
14873
16390
|
- `<p className="text-overline text-muted-foreground">Latest news</p>`
|