You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.
43 files+571−1210/43 viewed
| 556 | 556 | | **Workspace** | Usage, billing, integrations, policies and settings. | | |
| 557 | 557 | ||
| 558 | 558 | A bar on the dock's edge marks the app you are in. Your account is the | |
| 559 | − | avatar at the foot: your status, your profile and settings, the | |
| 560 | − | documentation, support, status, the keyboard's shortcuts, and signing out. | |
| 559 | + | avatar at the foot: your status, your profile and settings, | |
| 560 | + | [appearance](#appearance), the documentation, support, status, the | |
| 561 | + | keyboard's shortcuts, and signing out. | |
| 562 | + | ||
| 563 | + | ### Appearance | |
| 564 | + | ||
| 565 | + | g1t comes in a dark theme and a light one. Until you choose, it is dark. | |
| 566 | + | ||
| 567 | + | | Choice | What you see | | |
| 568 | + | | --- | --- | | |
| 569 | + | | **Auto** | Follows your system's light or dark setting, and changes when it does. | | |
| 570 | + | | **Light** | A near-white page with white panels and a deeper lavender accent. | | |
| 571 | + | | **Dark** | g1t's very dark gray with the lavender accent. | | |
| 572 | + | ||
| 573 | + | To change it: | |
| 561 | 574 | ||
| 575 | + | 1. Select your avatar at the foot of the dock. On a phone, open **More**, | |
| 576 | + | then **You and help**. | |
| 577 | + | 2. Under **Appearance**, select **Auto** (the screen icon), **Light** (the | |
| 578 | + | sun) or **Dark** (the moon). | |
| 579 | + | ||
| 580 | + | The page changes at once. You can also choose under | |
| 581 | + | [Settings → Account](https://g1t.sh/settings/account), or press | |
| 582 | + | <kbd>Ctrl</kbd> <kbd>K</kbd> (<kbd>⌘</kbd> <kbd>K</kbd> on a Mac) and run | |
| 583 | + | **Use light theme**, **Use dark theme** or **Use auto theme**. Signed out, | |
| 584 | + | the same commands are in ⌘K, and the phone's menu has the switch. | |
| 585 | + | ||
| 586 | + | Your choice is kept in this browser, in a `g1t_theme` cookie, so every page | |
| 587 | + | is drawn in it from the start; another browser or device starts dark. | |
| 588 | + | Product screenshots on g1t.sh's home page, and the g1t mark on agents' | |
| 589 | + | avatars, stay dark in either theme. | |
| 590 | + | ||
| 562 | 591 | ### Apps | |
| 563 | 592 | ||
| 564 | 593 | Every other app in the workspace is a page you can pin to your dock: | |
| ⋯ | |||
| 653 | 682 | | | What it does | | |
| 654 | 683 | | --- | --- | | |
| 655 | 684 | | **The bar along the bottom** | **Today**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It steps aside while the keyboard is up and inside a conversation. | | |
| 656 | − | | **More** | A panel above the bar with **All apps**, the Marketplace (coming), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, the documentation, support, status, the keyboard's shortcuts and signing out. | | |
| 685 | + | | **More** | A panel above the bar with **All apps**, the Marketplace (coming), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, [appearance](#appearance), the documentation, support, status, the keyboard's shortcuts and signing out. | | |
| 657 | 686 | | **The sidebar button**, at the left of the page's header | Opens the sidebar of the app you are in from the left: the same lists and links as on a computer. Tap outside it, or open a page, and it closes. | | |
| 658 | 687 | | **The tab you are already on** | Tap it again to open that app's sidebar too. | | |
| 659 | 688 | | **The workspace's name** in the page's header | Switches workspace, or opens your profile or settings. | | |
| 11 | 11 | /* Hanken and Bricolage want less tightening than a text face set close. */ | |
| 12 | 12 | --tracking-tight: -0.012em; | |
| 13 | 13 | ||
| 14 | − | --color-bg: var(--g1t-bg); | |
| 14 | + | /* | |
| 15 | + | * Every colour is a pair: light-dark() picks the light palette or the | |
| 16 | + | * dark one by the element's color-scheme, which follows the Appearance | |
| 17 | + | * setting (lib/theme.ts, set on <html> below). Something drawn dark on | |
| 18 | + | * purpose in either theme, such as a product screenshot, sets | |
| 19 | + | * `scheme-dark` and its tokens stay dark. | |
| 20 | + | */ | |
| 21 | + | --color-bg: light-dark(var(--g1t-light-bg), var(--g1t-bg)); | |
| 15 | 22 | /* Behind the page panel, where the sidebar sits flat (components/shell.tsx). */ | |
| 16 | − | --color-shell: #0b0b0d; | |
| 23 | + | --color-shell: light-dark(#ececea, #0b0b0d); | |
| 17 | 24 | /* The dock's own surface: a floating bar a step above the shell. */ | |
| 18 | − | --color-dock: #15151a; | |
| 19 | − | --color-surface: var(--g1t-surface); | |
| 20 | − | --color-raised: var(--g1t-raised); | |
| 21 | − | --color-line: var(--g1t-line); | |
| 22 | − | --color-line-strong: var(--g1t-line-strong); | |
| 25 | + | --color-dock: light-dark(#ffffff, #15151a); | |
| 26 | + | --color-surface: light-dark(var(--g1t-light-surface), var(--g1t-surface)); | |
| 27 | + | --color-raised: light-dark(var(--g1t-light-raised), var(--g1t-raised)); | |
| 28 | + | --color-line: light-dark(var(--g1t-light-line), var(--g1t-line)); | |
| 29 | + | --color-line-strong: light-dark(var(--g1t-light-line-strong), var(--g1t-line-strong)); | |
| 23 | 30 | ||
| 24 | − | --color-fg: var(--g1t-fg); | |
| 25 | − | --color-fg-soft: var(--g1t-fg-soft); | |
| 26 | − | --color-muted: var(--g1t-muted); | |
| 27 | − | --color-faint: var(--g1t-faint); | |
| 31 | + | --color-fg: light-dark(var(--g1t-light-fg), var(--g1t-fg)); | |
| 32 | + | --color-fg-soft: light-dark(var(--g1t-light-fg-soft), var(--g1t-fg-soft)); | |
| 33 | + | /* A primary button's hover: brighter than fg on dark, softer on light. */ | |
| 34 | + | --color-fg-hover: light-dark(#3a3a41, #ffffff); | |
| 35 | + | --color-muted: light-dark(var(--g1t-light-muted), var(--g1t-muted)); | |
| 36 | + | --color-faint: light-dark(var(--g1t-light-faint), var(--g1t-faint)); | |
| 28 | 37 | ||
| 29 | − | --color-accent: var(--g1t-accent); | |
| 30 | − | --color-accent-hover: var(--g1t-accent-hover); | |
| 31 | − | --color-accent-dim: var(--g1t-accent-dim); | |
| 38 | + | --color-accent: light-dark(var(--g1t-light-accent), var(--g1t-accent)); | |
| 39 | + | --color-accent-hover: light-dark(var(--g1t-light-accent-hover), var(--g1t-accent-hover)); | |
| 40 | + | --color-accent-dim: light-dark(var(--g1t-light-accent-dim), var(--g1t-accent-dim)); | |
| 41 | + | --color-accent-low: light-dark(var(--g1t-light-accent-low), var(--g1t-accent-low)); | |
| 42 | + | /* The accent at its palest on dark, a middle lavender on light: the headline's gradient. */ | |
| 43 | + | --color-accent-high: light-dark(#8b76e0, #d9d1ff); | |
| 32 | 44 | ||
| 33 | − | --color-success: var(--g1t-success); | |
| 34 | − | --color-success-dim: var(--g1t-success-dim); | |
| 45 | + | --color-success: light-dark(var(--g1t-light-success), var(--g1t-success)); | |
| 46 | + | --color-success-dim: light-dark(var(--g1t-light-success-dim), var(--g1t-success-dim)); | |
| 47 | + | --color-success-low: light-dark(var(--g1t-light-success-low), var(--g1t-success-low)); | |
| 35 | 48 | ||
| 36 | − | --color-info: var(--g1t-info); | |
| 37 | − | --color-merged: var(--g1t-merged); | |
| 38 | − | --color-warn: var(--g1t-warn); | |
| 39 | − | --color-danger: var(--g1t-danger); | |
| 49 | + | --color-info: light-dark(var(--g1t-light-info), var(--g1t-info)); | |
| 50 | + | --color-merged: light-dark(var(--g1t-light-merged), var(--g1t-merged)); | |
| 51 | + | --color-warn: light-dark(var(--g1t-light-warn), var(--g1t-warn)); | |
| 52 | + | --color-danger: light-dark(var(--g1t-light-danger), var(--g1t-danger)); | |
| 40 | 53 | ||
| 41 | 54 | --animate-fade-up: fade-up 0.6s ease-out both; | |
| 42 | 55 | --animate-dash: dash 1.2s linear infinite; | |
| ⋯ | |||
| 150 | 163 | } | |
| 151 | 164 | } | |
| 152 | 165 | ||
| 166 | + | /* | |
| 167 | + | * Appearance (lib/theme.ts): with no choice, or Auto, the page follows | |
| 168 | + | * the system; a choice of Light or Dark is data-theme on <html>. | |
| 169 | + | */ | |
| 153 | 170 | html { | |
| 154 | − | color-scheme: dark; | |
| 171 | + | color-scheme: light dark; | |
| 155 | 172 | scroll-behavior: smooth; | |
| 156 | 173 | /* A tap shows the control's own active state, not the browser's grey flash. */ | |
| 157 | 174 | -webkit-tap-highlight-color: transparent; | |
| ⋯ | |||
| 159 | 176 | -webkit-text-size-adjust: 100%; | |
| 160 | 177 | text-size-adjust: 100%; | |
| 161 | 178 | } | |
| 179 | + | html[data-theme="light"] { | |
| 180 | + | color-scheme: light; | |
| 181 | + | } | |
| 182 | + | html[data-theme="dark"] { | |
| 183 | + | color-scheme: dark; | |
| 184 | + | } | |
| 162 | 185 | ||
| 163 | 186 | /* | |
| 164 | 187 | * The app's frame (components/shell.tsx), set on <body data-frame="app">: | |
| ⋯ | |||
| 731 | 754 | @source "../../../node_modules/@blocknote/shadcn/dist"; | |
| 732 | 755 | ||
| 733 | 756 | @theme { | |
| 734 | − | --color-background: var(--g1t-bg); | |
| 735 | − | --color-foreground: var(--g1t-fg); | |
| 736 | − | --color-popover: var(--g1t-surface); | |
| 737 | − | --color-popover-foreground: var(--g1t-fg); | |
| 738 | − | --color-card: var(--g1t-surface); | |
| 739 | − | --color-card-foreground: var(--g1t-fg); | |
| 740 | − | --color-primary: var(--g1t-accent); | |
| 741 | − | --color-primary-foreground: var(--g1t-bg); | |
| 742 | − | --color-secondary: var(--g1t-raised); | |
| 743 | − | --color-secondary-foreground: var(--g1t-fg); | |
| 744 | − | --color-muted-foreground: var(--g1t-muted); | |
| 745 | − | --color-accent-foreground: var(--g1t-fg); | |
| 746 | − | --color-destructive: var(--g1t-danger); | |
| 747 | − | --color-destructive-foreground: var(--g1t-bg); | |
| 748 | − | --color-border: var(--g1t-line); | |
| 749 | − | --color-input: var(--g1t-line); | |
| 750 | − | --color-ring: var(--g1t-accent); | |
| 757 | + | --color-background: var(--color-bg); | |
| 758 | + | --color-foreground: var(--color-fg); | |
| 759 | + | --color-popover: var(--color-surface); | |
| 760 | + | --color-popover-foreground: var(--color-fg); | |
| 761 | + | --color-card: var(--color-surface); | |
| 762 | + | --color-card-foreground: var(--color-fg); | |
| 763 | + | --color-primary: var(--color-accent); | |
| 764 | + | --color-primary-foreground: var(--color-bg); | |
| 765 | + | --color-secondary: var(--color-raised); | |
| 766 | + | --color-secondary-foreground: var(--color-fg); | |
| 767 | + | --color-muted-foreground: var(--color-muted); | |
| 768 | + | --color-accent-foreground: var(--color-fg); | |
| 769 | + | --color-destructive: var(--color-danger); | |
| 770 | + | --color-destructive-foreground: var(--color-bg); | |
| 771 | + | --color-border: var(--color-line); | |
| 772 | + | --color-input: var(--color-line); | |
| 773 | + | --color-ring: var(--color-accent); | |
| 751 | 774 | } | |
| 752 | 775 | ||
| 753 | 776 | .bn-shadcn { | |
| 754 | − | --color-accent: var(--g1t-raised); | |
| 755 | − | --color-muted: var(--g1t-raised); | |
| 777 | + | --color-accent: var(--color-raised); | |
| 778 | + | --color-muted: var(--color-raised); | |
| 756 | 779 | --bn-colors-editor-background: transparent; | |
| 757 | − | --bn-colors-editor-text: var(--g1t-fg); | |
| 758 | − | --bn-colors-menu-background: var(--g1t-surface); | |
| 759 | − | --bn-colors-menu-text: var(--g1t-fg); | |
| 760 | − | --bn-colors-hovered-background: var(--g1t-raised); | |
| 761 | − | --bn-colors-side-menu: var(--g1t-faint); | |
| 762 | − | --bn-colors-border: var(--g1t-line); | |
| 780 | + | --bn-colors-editor-text: var(--color-fg); | |
| 781 | + | --bn-colors-menu-background: var(--color-surface); | |
| 782 | + | --bn-colors-menu-text: var(--color-fg); | |
| 783 | + | --bn-colors-hovered-background: var(--color-raised); | |
| 784 | + | --bn-colors-side-menu: var(--color-faint); | |
| 785 | + | --bn-colors-border: var(--color-line); | |
| 763 | 786 | --bn-border-radius: 8px; | |
| 764 | 787 | } | |
| 765 | 788 | ||
| ⋯ | |||
| 768 | 791 | font-size: 1rem; | |
| 769 | 792 | line-height: 1.65; | |
| 770 | 793 | background: transparent; | |
| 771 | − | color: var(--g1t-fg); | |
| 794 | + | color: var(--color-fg); | |
| 772 | 795 | } | |
| 773 | 796 | ||
| 774 | 797 | /* The drag handle and + sit in the margin, so the text lines up with the title. */ | |
| ⋯ | |||
| 790 | 813 | font-size: 0.875em; | |
| 791 | 814 | padding: 0.1em 0.35em; | |
| 792 | 815 | border-radius: 4px; | |
| 793 | − | background: var(--g1t-raised); | |
| 816 | + | background: var(--color-raised); | |
| 794 | 817 | } | |
| 795 | 818 | ||
| 796 | 819 | .g1t-editor .bn-block-content[data-content-type="codeBlock"] { | |
| 797 | 820 | border-radius: 10px; | |
| 798 | − | background: var(--g1t-surface); | |
| 799 | − | border: 1px solid var(--g1t-line); | |
| 821 | + | background: var(--color-surface); | |
| 822 | + | border: 1px solid var(--color-line); | |
| 800 | 823 | } | |
| 801 | 824 | ||
| 802 | 825 | .g1t-editor .bn-thread-mark { | |
| 803 | − | background: color-mix(in srgb, var(--g1t-warn) 22%, transparent); | |
| 804 | − | border-bottom: 2px solid color-mix(in srgb, var(--g1t-warn) 60%, transparent); | |
| 826 | + | background: color-mix(in srgb, var(--color-warn) 22%, transparent); | |
| 827 | + | border-bottom: 2px solid color-mix(in srgb, var(--color-warn) 60%, transparent); | |
| 805 | 828 | } | |
| 806 | 829 | ||
| 807 | 830 | .g1t-editor a { | |
| 808 | − | color: var(--g1t-accent); | |
| 831 | + | color: var(--color-accent); | |
| 809 | 832 | } | |
| 810 | 833 | ||
| 811 | 834 | /* A page read before the editor loads: the same measure as the editor. */ | |
| 135 | 135 | {said.notStarted.fix && ( | |
| 136 | 136 | <Link | |
| 137 | 137 | to={said.notStarted.fix.to} | |
| 138 | − | className="inline-flex items-center gap-1 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg hover:bg-white" | |
| 138 | + | className="inline-flex items-center gap-1 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg hover:bg-fg-hover" | |
| 139 | 139 | > | |
| 140 | 140 | {said.notStarted.fix.label} <ArrowUpRight size={12} /> | |
| 141 | 141 | </Link> |
| 200 | 200 | <SubmitButton | |
| 201 | 201 | fetcher={fetcher} | |
| 202 | 202 | pending="Sending…" | |
| 203 | − | className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 203 | + | className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50" | |
| 204 | 204 | > | |
| 205 | 205 | Send | |
| 206 | 206 | </SubmitButton> |
| 36 | 36 | <button | |
| 37 | 37 | type="button" | |
| 38 | 38 | onClick={() => choose("yes")} | |
| 39 | − | className="flex-1 rounded-lg bg-fg px-3 py-2 font-medium text-bg transition-colors hover:bg-white" | |
| 39 | + | className="flex-1 rounded-lg bg-fg px-3 py-2 font-medium text-bg transition-colors hover:bg-fg-hover" | |
| 40 | 40 | > | |
| 41 | 41 | Allow | |
| 42 | 42 | </button> |
| 330 | 330 | <Form method="post" className="mt-3 space-y-3 text-sm"> | |
| 331 | 331 | <input type="hidden" name="intent" value="ai-reload" /> | |
| 332 | 332 | <label className="flex items-center gap-2"> | |
| 333 | − | <input type="checkbox" name="enabled" defaultChecked={r.enabled} className="accent-[var(--g1t-accent)]" /> | |
| 333 | + | <input type="checkbox" name="enabled" defaultChecked={r.enabled} className="accent-[var(--color-accent)]" /> | |
| 334 | 334 | Reload from the saved card | |
| 335 | 335 | </label> | |
| 336 | 336 | <div className="flex flex-wrap items-center gap-2 text-muted"> | |
| ⋯ | |||
| 388 | 388 | <legend className="mb-1.5 text-xs text-muted">Alert at</legend> | |
| 389 | 389 | {[50, 75, 90, 100].map((level) => ( | |
| 390 | 390 | <label key={level} className="flex items-center gap-1.5"> | |
| 391 | − | <input type="checkbox" name="alert" value={String(level)} defaultChecked={levels.includes(level)} className="accent-[var(--g1t-accent)]" /> | |
| 391 | + | <input type="checkbox" name="alert" value={String(level)} defaultChecked={levels.includes(level)} className="accent-[var(--color-accent)]" /> | |
| 392 | 392 | {level}% | |
| 393 | 393 | </label> | |
| 394 | 394 | ))} | |
| 395 | 395 | </fieldset> | |
| 396 | 396 | <label className="flex items-center gap-2"> | |
| 397 | − | <input type="checkbox" name="pause" defaultChecked={pause} className="accent-[var(--g1t-accent)]" /> | |
| 397 | + | <input type="checkbox" name="pause" defaultChecked={pause} className="accent-[var(--color-accent)]" /> | |
| 398 | 398 | Pause usage at 100% | |
| 399 | 399 | <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span> | |
| 400 | 400 | </label> | |
| 163 | 163 | key={toast.id} | |
| 164 | 164 | role={toast.ok ? "status" : "alert"} | |
| 165 | 165 | className={cn( | |
| 166 | − | "pointer-events-auto flex w-full animate-pop-in items-start gap-2.5 rounded-xl bg-[#17171b] py-2.5 pr-2 pl-3 text-[0.8125rem] leading-snug text-fg shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 motion-reduce:animate-none", | |
| 166 | + | "pointer-events-auto flex w-full animate-pop-in items-start gap-2.5 rounded-xl bg-surface py-2.5 pr-2 pl-3 text-[0.8125rem] leading-snug text-fg shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 motion-reduce:animate-none", | |
| 167 | 167 | toast.ok ? "ring-success/35" : "ring-danger/45", | |
| 168 | 168 | )} | |
| 169 | 169 | > |
| 182 | 182 | if (!language || code.length > 20_000) return; | |
| 183 | 183 | let cancelled = false; | |
| 184 | 184 | void import("../../lib/shiki") | |
| 185 | − | .then(async ({ THEME, getHighlighter, languageNamed }) => { | |
| 185 | + | .then(async ({ getHighlighter, languageNamed, tokenRows }) => { | |
| 186 | 186 | const lang = languageNamed(language); | |
| 187 | 187 | if (!lang) return null; | |
| 188 | − | const core = await getHighlighter(); | |
| 189 | − | return core.codeToTokens(code, { lang, theme: THEME }).tokens.map((row) => row.map((token) => ({ content: token.content, color: token.color }))); | |
| 188 | + | return tokenRows(await getHighlighter(), code, lang); | |
| 190 | 189 | }) | |
| 191 | 190 | .then((tokens) => { | |
| 192 | 191 | if (!cancelled && tokens) setRows(tokens); |
| 77 | 77 | else navigate(to); | |
| 78 | 78 | }; | |
| 79 | 79 | const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean); | |
| 80 | − | const pages = commands | |
| 81 | − | .filter((command) => { | |
| 82 | − | const text = `${command.label} ${command.hint ?? ""}`.toLowerCase(); | |
| 83 | − | return words.every((word) => text.includes(word)); | |
| 84 | − | }) | |
| 85 | − | .slice(0, trimmed ? 8 : 12); | |
| 80 | + | const matching = commands.filter((command) => { | |
| 81 | + | const text = `${command.label} ${command.hint ?? ""}`.toLowerCase(); | |
| 82 | + | return words.every((word) => text.includes(word)); | |
| 83 | + | }); | |
| 84 | + | const pages = matching.filter((command) => command.to != null).slice(0, trimmed ? 8 : 12); | |
| 85 | + | // Things done in place, such as switching the theme, once something is typed. | |
| 86 | + | const actions = trimmed ? matching.filter((command) => command.run != null).slice(0, 6) : []; | |
| 86 | 87 | const itemClass = "gap-3 rounded-lg px-3 py-2"; | |
| 87 | 88 | ||
| 88 | 89 | return ( | |
| ⋯ | |||
| 165 | 166 | <CommandItem | |
| 166 | 167 | key={`${command.to}-${command.label}`} | |
| 167 | 168 | value={`page:${command.to}:${command.label}`} | |
| 168 | − | onSelect={() => go(command.to)} | |
| 169 | + | onSelect={() => go(command.to!)} | |
| 170 | + | className={itemClass} | |
| 171 | + | > | |
| 172 | + | <span className="shrink-0 text-faint">{command.icon}</span> | |
| 173 | + | <span className="min-w-0 grow truncate">{command.label}</span> | |
| 174 | + | {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>} | |
| 175 | + | </CommandItem> | |
| 176 | + | ))} | |
| 177 | + | </CommandGroup> | |
| 178 | + | )} | |
| 179 | + | {actions.length > 0 && ( | |
| 180 | + | <CommandGroup heading="Commands"> | |
| 181 | + | {actions.map((command) => ( | |
| 182 | + | <CommandItem | |
| 183 | + | key={`run-${command.label}`} | |
| 184 | + | value={`run:${command.label}`} | |
| 185 | + | onSelect={() => { | |
| 186 | + | onOpenChange(false); | |
| 187 | + | command.run!(); | |
| 188 | + | }} | |
| 169 | 189 | className={itemClass} | |
| 170 | 190 | > | |
| 171 | 191 | <span className="shrink-0 text-faint">{command.icon}</span> | |
| 10 | 10 | */ | |
| 11 | 11 | const Dialog = lazy(() => import("./command-palette-dialog")); | |
| 12 | 12 | ||
| 13 | − | /** A page or action the palette can jump to. */ | |
| 14 | − | export type PaletteCommand = { label: string; hint?: string; to: string; icon: ReactNode }; | |
| 13 | + | /** | |
| 14 | + | * A page the palette can jump to (`to`), or something it does in place | |
| 15 | + | * (`run`, listed under Commands), such as switching the theme. | |
| 16 | + | */ | |
| 17 | + | export type PaletteCommand = { label: string; hint?: string; icon: ReactNode } & ({ to: string; run?: undefined } | { run: () => void; to?: undefined }); | |
| 15 | 18 | ||
| 16 | 19 | /** Opens and closes the palette on ⌘K or Ctrl-K, from anywhere on the page. */ | |
| 17 | 20 | export function usePaletteShortcut(toggle: () => void) { |
| 199 | 199 | <button | |
| 200 | 200 | type="button" | |
| 201 | 201 | onClick={() => fetcher.submit({ intent: "keep", id: memory.id }, { method: "post", action })} | |
| 202 | − | className="inline-flex items-center gap-1 rounded-md bg-fg px-2 py-1 text-xs font-medium text-bg hover:bg-white" | |
| 202 | + | className="inline-flex items-center gap-1 rounded-md bg-fg px-2 py-1 text-xs font-medium text-bg hover:bg-fg-hover" | |
| 203 | 203 | > | |
| 204 | 204 | <Check size={12} /> | |
| 205 | 205 | Keep | |
| ⋯ | |||
| 457 | 457 | className="w-full rounded-md border border-line bg-bg py-2 pr-3 pl-9 text-sm outline-none placeholder:text-faint hover:border-line-strong focus:border-accent-dim" | |
| 458 | 458 | /> | |
| 459 | 459 | </label> | |
| 460 | − | <button type="submit" className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white"> | |
| 460 | + | <button type="submit" className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover"> | |
| 461 | 461 | {searching ? "Searching…" : "Search"} | |
| 462 | 462 | </button> | |
| 463 | 463 | </Form> | |
| 16 | 16 | import { useEffect, useId, useState, type ReactNode } from "react"; | |
| 17 | 17 | ||
| 18 | 18 | import { citationHref } from "../../../lib/folios"; | |
| 19 | + | import { useDrawnTheme } from "../../../lib/theme"; | |
| 19 | 20 | ||
| 20 | 21 | /** What an embed shows, from the site (`-/artifacts/api?embed=`). */ | |
| 21 | 22 | export type EmbedCard = { kind: "issue" | "pull" | "channel" | "project" | "page" | "link"; title: string; subtitle: string | null; state: string | null; href: string }; | |
| ⋯ | |||
| 65 | 66 | const id = useId().replace(/[^a-zA-Z0-9]/g, ""); | |
| 66 | 67 | const [svg, setSvg] = useState<string | null>(null); | |
| 67 | 68 | const [error, setError] = useState<string | null>(null); | |
| 69 | + | // Drawn again in the other theme's colours when the page switches. | |
| 70 | + | const theme = useDrawnTheme(); | |
| 68 | 71 | useEffect(() => { | |
| 69 | 72 | let cancelled = false; | |
| 70 | 73 | const timer = setTimeout(async () => { | |
| 71 | 74 | try { | |
| 72 | 75 | const mermaid = (await import("mermaid")).default; | |
| 73 | − | mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict", fontFamily: "inherit" }); | |
| 76 | + | mermaid.initialize({ startOnLoad: false, theme: theme === "light" ? "default" : "dark", securityLevel: "strict", fontFamily: "inherit" }); | |
| 74 | 77 | const { svg } = await mermaid.render(`mermaid-${id}-${Date.now()}`, code || "flowchart LR\n A --> B"); | |
| 75 | 78 | if (!cancelled) { | |
| 76 | 79 | setSvg(svg); | |
| ⋯ | |||
| 84 | 87 | cancelled = true; | |
| 85 | 88 | clearTimeout(timer); | |
| 86 | 89 | }; | |
| 87 | − | }, [code, id]); | |
| 90 | + | }, [code, id, theme]); | |
| 88 | 91 | if (error) return <p className="text-xs text-danger">{error}</p>; | |
| 89 | 92 | if (!svg) return <p className="text-xs text-faint">Drawing…</p>; | |
| 90 | 93 | // Mermaid sanitizes what it draws (securityLevel "strict"). | |
| ⋯ | |||
| 104 | 107 | onChange={(e) => setDraft(e.target.value)} | |
| 105 | 108 | onBlur={() => draft !== value && onChange(draft)} | |
| 106 | 109 | onKeyDown={(e) => e.stopPropagation()} | |
| 107 | − | className={`w-full resize-y rounded-md border border-line bg-bg px-2.5 py-2 text-[0.8125rem] leading-relaxed text-fg outline-none focus:border-[var(--g1t-accent)] ${mono ? "font-mono" : ""}`} | |
| 110 | + | className={`w-full resize-y rounded-md border border-line bg-bg px-2.5 py-2 text-[0.8125rem] leading-relaxed text-fg outline-none focus:border-[var(--color-accent)] ${mono ? "font-mono" : ""}`} | |
| 108 | 111 | /> | |
| 109 | 112 | ); | |
| 110 | 113 | } | |
| ⋯ | |||
| 262 | 265 | const p = inlineContent.props; | |
| 263 | 266 | if (p.kind === "page") { | |
| 264 | 267 | return ( | |
| 265 | − | <a href={p.href || "#"} className="rounded px-0.5 font-medium text-[var(--g1t-accent)] no-underline hover:underline"> | |
| 268 | + | <a href={p.href || "#"} className="rounded px-0.5 font-medium text-[var(--color-accent)] no-underline hover:underline"> | |
| 266 | 269 | <FileText size={13} className="mr-0.5 inline -translate-y-px" aria-hidden="true" /> | |
| 267 | 270 | {p.name || "Untitled"} | |
| 268 | 271 | </a> | |
| 269 | 272 | ); | |
| 270 | 273 | } | |
| 271 | − | return <span className={`rounded px-1 py-px font-medium ${p.kind === "agent" ? "bg-[color-mix(in_srgb,var(--g1t-accent)_15%,transparent)] text-[var(--g1t-accent)]" : "bg-info/12 text-info"}`}>@{p.name}</span>; | |
| 274 | + | return <span className={`rounded px-1 py-px font-medium ${p.kind === "agent" ? "bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-[var(--color-accent)]" : "bg-info/12 text-info"}`}>@{p.name}</span>; | |
| 272 | 275 | }, | |
| 273 | 276 | }, | |
| 274 | 277 | ); | |
| 18 | 18 | import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 19 | 19 | ||
| 20 | 20 | import { canDo, cursorColour } from "../../../lib/folios"; | |
| 21 | + | import { useDrawnTheme } from "../../../lib/theme"; | |
| 21 | 22 | import { schema, type DocEditorInstance } from "./blocks"; | |
| 22 | 23 | import { CiteDialog } from "./code"; | |
| 23 | 24 | import { EditorSkeleton } from "./editor-skeleton"; | |
| ⋯ | |||
| 114 | 115 | ||
| 115 | 116 | function LiveEditor({ slug, folioId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps & { provider: FolioProvider }) { | |
| 116 | 117 | const editable = canDo(role, "edit"); | |
| 118 | + | // BlockNote's own parts follow the page's Appearance (lib/theme.ts). | |
| 119 | + | const theme = useDrawnTheme(); | |
| 117 | 120 | const [citing, setCiting] = useState(false); | |
| 118 | 121 | const colour = cursorColour(me.name); | |
| 119 | 122 | // Below 1280px the comments sit under the page: opening them goes there. | |
| ⋯ | |||
| 308 | 311 | {struck && ( | |
| 309 | 312 | <style>{`${struck} { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--g1t-danger) 70%, transparent); background: color-mix(in srgb, var(--g1t-danger) 8%, transparent); border-radius: 4px; }`}</style> | |
| 310 | 313 | )} | |
| 311 | − | <BlockNoteView editor={editor as never} editable={editable} theme="dark" renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn"> | |
| 314 | + | <BlockNoteView editor={editor as never} editable={editable} theme={theme} renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn"> | |
| 312 | 315 | <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}> | |
| 313 | 316 | <div className="relative min-w-0"> | |
| 314 | 317 | <BlockNoteViewEditor /> | |
| 351 | 351 | <div className="relative mx-auto max-w-6xl px-4 pt-20 text-center sm:pt-24"> | |
| 352 | 352 | <Link | |
| 353 | 353 | to={`${DOCS}/guides/chat/`} | |
| 354 | − | className="inline-flex animate-fade-up items-center gap-2 rounded-full bg-bg/50 px-3 py-1 text-xs text-fg-soft ring-1 ring-white/10 backdrop-blur transition-colors hover:bg-bg/70" | |
| 354 | + | className="inline-flex animate-fade-up items-center gap-2 rounded-full bg-bg/50 px-3 py-1 text-xs text-fg-soft ring-1 ring-line backdrop-blur transition-colors hover:bg-bg/70" | |
| 355 | 355 | > | |
| 356 | 356 | <span className="size-1.5 shrink-0 rounded-full bg-accent" /> | |
| 357 | 357 | For engineering teams running AI coding agents: chat, agents, docs and code in one workspace | |
| ⋯ | |||
| 359 | 359 | </Link> | |
| 360 | 360 | <h1 className="mx-auto mt-7 max-w-4xl animate-fade-up text-[2.75rem] leading-[1.04] font-semibold tracking-tight text-balance sm:text-7xl"> | |
| 361 | 361 | Your team and its agents,{" "} | |
| 362 | − | <span className="bg-gradient-to-r from-fg via-[#d9d1ff] to-accent bg-clip-text text-transparent"> | |
| 362 | + | <span className="bg-gradient-to-r from-fg via-accent-high to-accent bg-clip-text text-transparent"> | |
| 363 | 363 | working in one place. | |
| 364 | 364 | </span> | |
| 365 | 365 | </h1> | |
| 28 | 28 | y={p.y * 10 + 1} | |
| 29 | 29 | width="8" | |
| 30 | 30 | height="8" | |
| 31 | − | fill={p.one ? "var(--g1t-accent)" : "currentColor"} | |
| 31 | + | fill={p.one ? "var(--color-accent)" : "currentColor"} | |
| 32 | 32 | /> | |
| 33 | 33 | )); | |
| 34 | 34 | } |
| 270 | 270 | <Link | |
| 271 | 271 | to={row.to} | |
| 272 | 272 | prefetch="intent" | |
| 273 | − | className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-white" | |
| 273 | + | className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-fg-hover" | |
| 274 | 274 | > | |
| 275 | 275 | Review and respond <ArrowUpRight size={14} /> | |
| 276 | 276 | </Link> | |
| ⋯ | |||
| 654 | 654 | {!step.done && step.to && ( | |
| 655 | 655 | <Link | |
| 656 | 656 | to={step.to} | |
| 657 | − | className="shrink-0 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg transition-colors hover:bg-white" | |
| 657 | + | className="shrink-0 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg transition-colors hover:bg-fg-hover" | |
| 658 | 658 | > | |
| 659 | 659 | {step.action} | |
| 660 | 660 | </Link> | |
| ⋯ | |||
| 1031 | 1031 | action={ | |
| 1032 | 1032 | <Link | |
| 1033 | 1033 | to={workspace ? `/new?workspace=${workspace}` : "/new"} | |
| 1034 | − | className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-white" | |
| 1034 | + | className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover" | |
| 1035 | 1035 | > | |
| 1036 | 1036 | Create or import a project | |
| 1037 | 1037 | </Link> | |
| 27 | 27 | import { Avatar } from "./ui"; | |
| 28 | 28 | import { TabStrip } from "./ui/tab-strip"; | |
| 29 | 29 | import { StatusDialog } from "./presence"; | |
| 30 | + | import { ThemeSwitch } from "./theme-switch"; | |
| 30 | 31 | import { type AppKey, type PinnableApp, appOf } from "../lib/apps"; | |
| 31 | 32 | import { setPresence, useOwnPresence } from "../lib/notify-client"; | |
| 32 | 33 | import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence"; | |
| ⋯ | |||
| 464 | 465 | Your settings | |
| 465 | 466 | </SheetRow> | |
| 466 | 467 | </SheetGroup> | |
| 468 | + | <SheetGroup title="Appearance"> | |
| 469 | + | <ThemeSwitch size="large" className="mx-3 mt-1 mb-1.5" /> | |
| 470 | + | </SheetGroup> | |
| 467 | 471 | <SheetGroup title="Help"> | |
| 468 | 472 | <SheetRow href="https://docs.g1t.sh/" icon={<BookOpen />}> | |
| 469 | 473 | Documentation | |
| 119 | 119 | return ( | |
| 120 | 120 | <div | |
| 121 | 121 | role="status" | |
| 122 | − | className="g1t-toast group/toast pointer-events-auto relative overflow-hidden rounded-xl bg-[#17171b] shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 ring-line-strong" | |
| 122 | + | className="g1t-toast group/toast pointer-events-auto relative overflow-hidden rounded-xl bg-surface shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 ring-line-strong" | |
| 123 | 123 | > | |
| 124 | 124 | {loud && <span aria-hidden="true" className="absolute inset-y-0 left-0 w-[3px] bg-accent" />} | |
| 125 | 125 | <button type="button" onClick={open} className="flex w-full items-start gap-3 p-3 pr-9 text-left outline-none focus-visible:bg-raised/60"> | |
| ⋯ | |||
| 163 | 163 | function PushOffer() { | |
| 164 | 164 | const [busy, setBusy] = useState(false); | |
| 165 | 165 | return ( | |
| 166 | − | <div className="g1t-toast pointer-events-auto flex items-center gap-3 rounded-xl bg-[#17171b] p-3 shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 ring-accent/35"> | |
| 166 | + | <div className="g1t-toast pointer-events-auto flex items-center gap-3 rounded-xl bg-surface p-3 shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 ring-accent/35"> | |
| 167 | 167 | <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/15 text-accent"> | |
| 168 | 168 | <BellRing size={16} /> | |
| 169 | 169 | </span> | |
| ⋯ | |||
| 254 | 254 | </div> | |
| 255 | 255 | )} | |
| 256 | 256 | {folded.length > 0 && ( | |
| 257 | − | <div className="pointer-events-auto flex h-[30px] items-center justify-between gap-2 self-center rounded-full bg-[#17171b] pr-1 pl-3 text-xs text-muted shadow-[0_0_0_6px_var(--color-bg),0_10px_30px_-10px_rgba(0,0,0,0.9)] ring-1 ring-line-strong"> | |
| 257 | + | <div className="pointer-events-auto flex h-[30px] items-center justify-between gap-2 self-center rounded-full bg-surface pr-1 pl-3 text-xs text-muted shadow-[0_0_0_6px_var(--color-bg),0_10px_30px_-10px_rgba(0,0,0,0.9)] ring-1 ring-line-strong"> | |
| 258 | 258 | <span className="font-medium text-fg tabular-nums">+{folded.length} more</span> | |
| 259 | 259 | <button | |
| 260 | 260 | type="button" | |
| 15 | 15 | return ( | |
| 16 | 16 | <span | |
| 17 | 17 | aria-hidden="true" | |
| 18 | − | className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] ring-1 ring-line-strong ring-inset" | |
| 18 | + | className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark" | |
| 19 | 19 | style={{ width: size, height: size, borderRadius: Math.round(size * 0.26) }} | |
| 20 | 20 | > | |
| 21 | 21 | <Mark className="size-full" /> |
| 1237 | 1237 | docs: <DocsMain frame={frame} />, | |
| 1238 | 1238 | }; | |
| 1239 | 1239 | return ( | |
| 1240 | − | <div ref={outer} className="relative w-full overflow-hidden rounded-2xl shadow-2xl shadow-black/50 ring-1 ring-line" style={{ aspectRatio: `${W} / ${H}` }}> | |
| 1240 | + | <div ref={outer} className="relative w-full overflow-hidden rounded-2xl text-fg shadow-2xl shadow-black/50 ring-1 ring-line scheme-dark" style={{ aspectRatio: `${W} / ${H}` }}> | |
| 1241 | 1241 | <div | |
| 1242 | 1242 | ref={canvas} | |
| 1243 | 1243 | className="absolute top-0 left-0 flex origin-top-left bg-bg text-left" | |
| ⋯ | |||
| 1296 | 1296 | function Phone({ frame }: { frame: Frame }) { | |
| 1297 | 1297 | const scenes: Scene[] = ["code", "chat", "agents", "docs"]; | |
| 1298 | 1298 | return ( | |
| 1299 | − | <div className="relative flex h-[34rem] w-full flex-col overflow-hidden rounded-2xl bg-bg text-left shadow-2xl shadow-black/50 ring-1 ring-line"> | |
| 1299 | + | <div className="relative flex h-[34rem] w-full flex-col overflow-hidden rounded-2xl bg-bg text-left text-fg shadow-2xl shadow-black/50 ring-1 ring-line scheme-dark"> | |
| 1300 | 1300 | <div className="flex h-12 shrink-0 items-center gap-2 border-b border-line px-3"> | |
| 1301 | 1301 | <Avatar name="acme" size={26} square /> | |
| 1302 | 1302 | <span key={frame.scene} className="tour-fade flex min-w-0 items-center gap-1.5 text-[13px] font-semibold text-fg"> | |
| 12 | 12 | import type { User } from "@g1t/contracts"; | |
| 13 | 13 | ||
| 14 | 14 | import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette"; | |
| 15 | + | import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch"; | |
| 15 | 16 | import { Logo } from "./logo"; | |
| 16 | 17 | import { Avatar, ButtonLink, notACredential } from "./ui"; | |
| 17 | 18 | import { | |
| ⋯ | |||
| 93 | 94 | <CommandPalette | |
| 94 | 95 | open={palette} | |
| 95 | 96 | onOpenChange={setPalette} | |
| 96 | − | commands={user ? PUBLIC_COMMANDS.slice(0, 4) : PUBLIC_COMMANDS.map((command) => (command.to === "/register" ? { ...command, label: signUp.primary } : command))} | |
| 97 | + | commands={[ | |
| 98 | + | ...(user ? PUBLIC_COMMANDS.slice(0, 4) : PUBLIC_COMMANDS.map((command) => (command.to === "/register" ? { ...command, label: signUp.primary } : command))), | |
| 99 | + | ...THEME_COMMANDS, | |
| 100 | + | ]} | |
| 97 | 101 | repo={repo} | |
| 98 | 102 | /> | |
| 99 | 103 | <nav aria-label="Main" className="hidden items-center gap-0.5 sm:flex"> | |
| ⋯ | |||
| 122 | 126 | Docs | |
| 123 | 127 | </Link> | |
| 124 | 128 | </DropdownMenuItem> | |
| 129 | + | <DropdownMenuSeparator /> | |
| 130 | + | <ThemeMenuSwitch className="px-2" /> | |
| 125 | 131 | {!user && ( | |
| 126 | 132 | <> | |
| 127 | 133 | <DropdownMenuSeparator /> | |
| ⋯ | |||
| 166 | 172 | </Link> | |
| 167 | 173 | </DropdownMenuItem> | |
| 168 | 174 | <DropdownMenuSeparator /> | |
| 175 | + | <ThemeMenuSwitch className="px-2" /> | |
| 176 | + | <DropdownMenuSeparator /> | |
| 169 | 177 | {/* Submitted from here: the menu closes on select, and a button | |
| 170 | 178 | that has left the page cannot submit a form. */} | |
| 171 | 179 | <DropdownMenuItem | |
| 37 | 37 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| 38 | 38 | const SMALL_BUTTON = | |
| 39 | 39 | "rounded-md border border-line px-2.5 py-1 text-xs font-medium text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-50"; | |
| 40 | − | const PRIMARY = "rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-white disabled:opacity-50"; | |
| 40 | + | const PRIMARY = "rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-fg-hover disabled:opacity-50"; | |
| 41 | 41 | ||
| 42 | 42 | /** A section's title, what it is for, and what can be done there. */ | |
| 43 | 43 | export function SectionHeader({ title, about, actions }: { title: string; about: ReactNode; actions?: ReactNode }) { |
| 217 | 217 | <button | |
| 218 | 218 | type="submit" | |
| 219 | 219 | disabled={fetcher.state !== "idle" || !reason} | |
| 220 | − | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 220 | + | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50" | |
| 221 | 221 | > | |
| 222 | 222 | {fetcher.state !== "idle" ? "Dismissing…" : "Dismiss alert"} | |
| 223 | 223 | </button> |
| 45 | 45 | import type { AccountMenuData } from "../routes/settings-menu-json"; | |
| 46 | 46 | import { useLiveBadges } from "../lib/notify-client"; | |
| 47 | 47 | import { OwnPresenceDot, OwnPresenceItems, StatusDialog } from "./presence"; | |
| 48 | + | import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch"; | |
| 48 | 49 | ||
| 49 | 50 | /** | |
| 50 | 51 | * What the sidebar needs, worked out by the root loader. For a visitor who | |
| ⋯ | |||
| 410 | 411 | )} | |
| 411 | 412 | </DropdownMenuGroup> | |
| 412 | 413 | <DropdownMenuSeparator className="my-1.5" /> | |
| 414 | + | {/* Auto, Light or Dark, switched in place (lib/theme.ts). */} | |
| 415 | + | <ThemeMenuSwitch /> | |
| 416 | + | <DropdownMenuSeparator className="my-1.5" /> | |
| 413 | 417 | <DropdownMenuGroup> | |
| 414 | 418 | <DropdownMenuItem asChild className={MENU_ROW}> | |
| 415 | 419 | <a href="https://docs.g1t.sh/"> | |
| ⋯ | |||
| 1561 | 1565 | ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[]) | |
| 1562 | 1566 | .filter((page) => page !== "profile") | |
| 1563 | 1567 | .map((page) => ({ label: ACCOUNT_SETTINGS[page].title, hint: "Your settings", to: `/settings/${page}`, icon: <Settings size={15} /> })), | |
| 1568 | + | ...THEME_COMMANDS, | |
| 1564 | 1569 | ]; | |
| 1565 | 1570 | const repo = shell.repo; | |
| 1566 | 1571 | if (repo) { | |
| 1 | + | import { Monitor, Moon, Sun } from "lucide-react"; | |
| 2 | + | import { DropdownMenu as Menu } from "radix-ui"; | |
| 3 | + | import { type KeyboardEvent, type ReactNode, useRef } from "react"; | |
| 4 | + | import { useRouteLoaderData } from "react-router"; | |
| 5 | + | ||
| 6 | + | import { cn } from "../lib/cn"; | |
| 7 | + | import { THEME_CHOICES, THEME_LABEL, type ThemeChoice, setTheme, useThemeChoice } from "../lib/theme"; | |
| 8 | + | import type { PaletteCommand } from "./command-palette"; | |
| 9 | + | import { Hint } from "./ui/hint"; | |
| 10 | + | ||
| 11 | + | /** The person's Appearance: as the page was drawn with it, or as switched since. */ | |
| 12 | + | export function useTheme(): ThemeChoice { | |
| 13 | + | const root = useRouteLoaderData("root") as { theme?: ThemeChoice } | undefined; | |
| 14 | + | return useThemeChoice(root?.theme); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | const ICON: Record<ThemeChoice, (size: number) => ReactNode> = { | |
| 18 | + | auto: (size) => <Monitor size={size} />, | |
| 19 | + | light: (size) => <Sun size={size} />, | |
| 20 | + | dark: (size) => <Moon size={size} />, | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | const TRACK = "grid grid-cols-3 gap-0.5 rounded-lg bg-raised p-0.5 ring-1 ring-line ring-inset"; | |
| 24 | + | const SEGMENT = | |
| 25 | + | "flex min-w-0 items-center justify-center gap-1.5 rounded-md font-medium text-muted outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[on=true]:bg-surface data-[on=true]:text-fg data-[on=true]:shadow-[0_0_0_1px_var(--color-line-strong),0_1px_2px_rgb(0_0_0/0.12)]"; | |
| 26 | + | ||
| 27 | + | /** | |
| 28 | + | * Auto, Light and Dark side by side, the current one raised: on a | |
| 29 | + | * settings page, or in a phone's sheet (`size="large"`). A radio group: | |
| 30 | + | * the arrow keys move between them. | |
| 31 | + | */ | |
| 32 | + | export function ThemeSwitch({ size = "small", className }: { size?: "small" | "large"; className?: string }) { | |
| 33 | + | const theme = useTheme(); | |
| 34 | + | const buttons = useRef<(HTMLButtonElement | null)[]>([]); | |
| 35 | + | const move = (event: KeyboardEvent, index: number) => { | |
| 36 | + | const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; | |
| 37 | + | if (!step) return; | |
| 38 | + | event.preventDefault(); | |
| 39 | + | const next = THEME_CHOICES[(index + step + THEME_CHOICES.length) % THEME_CHOICES.length]!; | |
| 40 | + | setTheme(next); | |
| 41 | + | buttons.current[THEME_CHOICES.indexOf(next)]?.focus(); | |
| 42 | + | }; | |
| 43 | + | return ( | |
| 44 | + | <div role="radiogroup" aria-label="Appearance" className={cn(TRACK, className)}> | |
| 45 | + | {THEME_CHOICES.map((choice, index) => ( | |
| 46 | + | <button | |
| 47 | + | key={choice} | |
| 48 | + | ref={(element) => { | |
| 49 | + | buttons.current[index] = element; | |
| 50 | + | }} | |
| 51 | + | type="button" | |
| 52 | + | role="radio" | |
| 53 | + | aria-checked={theme === choice} | |
| 54 | + | tabIndex={theme === choice ? 0 : -1} | |
| 55 | + | data-on={theme === choice} | |
| 56 | + | onClick={() => setTheme(choice)} | |
| 57 | + | onKeyDown={(event) => move(event, index)} | |
| 58 | + | className={cn(SEGMENT, size === "large" ? "h-10 text-sm" : "h-7 text-xs")} | |
| 59 | + | > | |
| 60 | + | {ICON[choice](size === "large" ? 16 : 13)} | |
| 61 | + | {THEME_LABEL[choice]} | |
| 62 | + | </button> | |
| 63 | + | ))} | |
| 64 | + | </div> | |
| 65 | + | ); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /** The palette's commands for switching: "Use light theme" and the rest (components/command-palette.tsx). */ | |
| 69 | + | export const THEME_COMMANDS: PaletteCommand[] = [ | |
| 70 | + | { label: "Use light theme", hint: "Appearance", icon: ICON.light(15), run: () => setTheme("light") }, | |
| 71 | + | { label: "Use dark theme", hint: "Appearance", icon: ICON.dark(15), run: () => setTheme("dark") }, | |
| 72 | + | { label: "Use auto theme", hint: "Appearance · Follow the system", icon: ICON.auto(15), run: () => setTheme("auto") }, | |
| 73 | + | ]; | |
| 74 | + | ||
| 75 | + | /** | |
| 76 | + | * The same switch as a row of the account menu: a group of menu radio | |
| 77 | + | * items, so the menu's arrow keys reach it, that switch without closing | |
| 78 | + | * the menu. | |
| 79 | + | */ | |
| 80 | + | export function ThemeMenuSwitch({ className }: { className?: string }) { | |
| 81 | + | const theme = useTheme(); | |
| 82 | + | return ( | |
| 83 | + | <div className={cn("flex items-center gap-2.5 px-2.5 py-1", className)}> | |
| 84 | + | <span className="grow text-[0.8125rem] text-fg/90">Appearance</span> | |
| 85 | + | <Menu.RadioGroup value={theme} onValueChange={(value) => setTheme(value as ThemeChoice)} aria-label="Appearance" className={cn(TRACK, "w-[7.5rem] shrink-0")}> | |
| 86 | + | {THEME_CHOICES.map((choice) => ( | |
| 87 | + | <Hint key={choice} label={THEME_LABEL[choice]} side="bottom"> | |
| 88 | + | <Menu.RadioItem | |
| 89 | + | value={choice} | |
| 90 | + | aria-label={THEME_LABEL[choice]} | |
| 91 | + | data-on={theme === choice} | |
| 92 | + | // Switching keeps the menu open, so the change can be seen. | |
| 93 | + | onSelect={(event) => event.preventDefault()} | |
| 94 | + | className={cn(SEGMENT, "h-7 cursor-default data-highlighted:text-fg data-highlighted:ring-1 data-highlighted:ring-line-strong")} | |
| 95 | + | > | |
| 96 | + | {ICON[choice](14)} | |
| 97 | + | </Menu.RadioItem> | |
| 98 | + | </Hint> | |
| 99 | + | ))} | |
| 100 | + | </Menu.RadioGroup> | |
| 101 | + | </div> | |
| 102 | + | ); | |
| 103 | + | } |
| 118 | 118 | "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors disabled:opacity-50"; | |
| 119 | 119 | ||
| 120 | 120 | const BUTTON_VARIANTS: Record<Variant, string> = { | |
| 121 | − | primary: "bg-fg text-bg hover:bg-white", | |
| 121 | + | primary: "bg-fg text-bg hover:bg-fg-hover", | |
| 122 | 122 | accent: "bg-accent text-bg hover:bg-accent-hover", | |
| 123 | 123 | quiet: | |
| 124 | 124 | "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg", | |
| ⋯ | |||
| 315 | 315 | return ( | |
| 316 | 316 | <span | |
| 317 | 317 | aria-hidden="true" | |
| 318 | − | className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset" | |
| 318 | + | className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark" | |
| 319 | 319 | style={{ width: size, height: size, borderRadius: size * 0.24 }} | |
| 320 | 320 | > | |
| 321 | 321 | <Mark className="size-full" /> | |
| 69 | 69 | {options.map((option) => ( | |
| 70 | 70 | <li key={option.value}> | |
| 71 | 71 | <label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-surface"> | |
| 72 | − | <input type="checkbox" name={name} value={option.value} defaultChecked={chosen.includes(option.value)} className="accent-[var(--g1t-accent)]" /> | |
| 72 | + | <input type="checkbox" name={name} value={option.value} defaultChecked={chosen.includes(option.value)} className="accent-[var(--color-accent)]" /> | |
| 73 | 73 | <span className="truncate">{option.label}</span> | |
| 74 | 74 | </label> | |
| 75 | 75 | </li> | |
| 76 | 76 | ))} | |
| 77 | 77 | </ul> | |
| 78 | 78 | )} | |
| 79 | − | <button type="submit" className="mt-2 w-full rounded-md bg-fg px-2 py-1.5 text-sm font-medium text-bg hover:bg-white"> | |
| 79 | + | <button type="submit" className="mt-2 w-full rounded-md bg-fg px-2 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover"> | |
| 80 | 80 | Apply | |
| 81 | 81 | </button> | |
| 82 | 82 | </div> | |
| ⋯ | |||
| 374 | 374 | /> | |
| 375 | 375 | ))} | |
| 376 | 376 | </div> | |
| 377 | − | <div className="absolute inset-y-0 left-1/2 -z-0 w-full -translate-x-1/2 rounded group-hover:bg-white/[0.03] group-focus-visible:bg-white/[0.05]" aria-hidden="true" /> | |
| 377 | + | <div className="absolute inset-y-0 left-1/2 -z-0 w-full -translate-x-1/2 rounded group-hover:bg-fg/[0.04] group-focus-visible:bg-fg/[0.06]" aria-hidden="true" /> | |
| 378 | 378 | <div | |
| 379 | 379 | className={cn( | |
| 380 | 380 | "pointer-events-none absolute bottom-full z-10 mb-2 hidden w-52 rounded-lg border border-line-strong bg-raised p-2.5 text-xs shadow-xl group-hover:block group-focus-visible:block", | |
| 54 | 54 | const hue = LABEL_HUES[name] ?? hueFor(name); | |
| 55 | 55 | const own = chipStyle(color); | |
| 56 | 56 | const style: CSSProperties = Object.keys(own).length > 0 ? own : { | |
| 57 | − | color: `oklch(0.84 0.11 ${hue})`, | |
| 58 | − | borderColor: `oklch(0.84 0.11 ${hue} / 0.35)`, | |
| 59 | − | backgroundColor: `oklch(0.84 0.11 ${hue} / 0.1)`, | |
| 57 | + | // Light text on a dark page, deep text on a light one (lib/theme.ts). | |
| 58 | + | color: `light-dark(oklch(0.46 0.13 ${hue}), oklch(0.84 0.11 ${hue}))`, | |
| 59 | + | borderColor: `light-dark(oklch(0.55 0.13 ${hue} / 0.35), oklch(0.84 0.11 ${hue} / 0.35))`, | |
| 60 | + | backgroundColor: `light-dark(oklch(0.55 0.13 ${hue} / 0.08), oklch(0.84 0.11 ${hue} / 0.1))`, | |
| 60 | 61 | }; | |
| 61 | 62 | return ( | |
| 62 | 63 | <span |
| 69 | 69 | }, | |
| 70 | 70 | account: { | |
| 71 | 71 | title: "Account", | |
| 72 | − | about: "Your username, and deleting your account.", | |
| 72 | + | about: "Your username, how g1t looks to you, and deleting your account.", | |
| 73 | 73 | }, | |
| 74 | 74 | }; | |
| 75 | 75 |
| 23 | 23 | * once. Bump `HIGHLIGHT_VERSION` when the theme, the grammars, Shiki or | |
| 24 | 24 | * `linesToHtml` change what they make. | |
| 25 | 25 | */ | |
| 26 | − | export const HIGHLIGHT_VERSION = "v1"; | |
| 26 | + | export const HIGHLIGHT_VERSION = "v2"; | |
| 27 | 27 | ||
| 28 | 28 | function dataCentre(): SharedCache | null { | |
| 29 | 29 | try { |
| 34 | 34 | dockerfile: "docker", | |
| 35 | 35 | }; | |
| 36 | 36 | ||
| 37 | − | export const THEME = "vitesse-dark"; | |
| 37 | + | /** | |
| 38 | + | * Code is coloured for both themes at once: each token's colour is | |
| 39 | + | * `light-dark(light, dark)`, so the page's Appearance (lib/theme.ts) | |
| 40 | + | * picks one with no second pass, and highlighted HTML kept in a cache is | |
| 41 | + | * right in either. | |
| 42 | + | */ | |
| 43 | + | export const THEMES = { light: "vitesse-light", dark: "vitesse-dark" } as const; | |
| 38 | 44 | ||
| 39 | 45 | /** The language of a file, from its name, if it is one g1t highlights. */ | |
| 40 | 46 | export function languageOf(path: string): string | null { | |
| ⋯ | |||
| 53 | 59 | ||
| 54 | 60 | export function getHighlighter(): Promise<HighlighterCore> { | |
| 55 | 61 | highlighter ??= createHighlighterCore({ | |
| 56 | − | themes: [import("shiki/themes/vitesse-dark.mjs")], | |
| 62 | + | themes: [import("shiki/themes/vitesse-light.mjs"), import("shiki/themes/vitesse-dark.mjs")], | |
| 57 | 63 | langs: [ | |
| 58 | 64 | import("shiki/langs/rust.mjs"), | |
| 59 | 65 | import("shiki/langs/typescript.mjs"), | |
| ⋯ | |||
| 82 | 88 | return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 83 | 89 | } | |
| 84 | 90 | ||
| 91 | + | /** A piece of a line and its colour, a `light-dark()` pair, if it has one. */ | |
| 92 | + | export type CodeToken = { content: string; color?: string }; | |
| 93 | + | ||
| 94 | + | /** Each line of `text` as coloured tokens, for both themes. */ | |
| 95 | + | export function tokenRows(core: HighlighterCore, text: string, lang: string): CodeToken[][] { | |
| 96 | + | return core.codeToTokensWithThemes(text, { lang, themes: THEMES }).map((row) => | |
| 97 | + | row.map((token) => { | |
| 98 | + | const light = token.variants.light?.color; | |
| 99 | + | const dark = token.variants.dark?.color; | |
| 100 | + | const color = light && dark ? `light-dark(${light},${dark})` : (light ?? dark); | |
| 101 | + | return color ? { content: token.content, color } : { content: token.content }; | |
| 102 | + | }), | |
| 103 | + | ); | |
| 104 | + | } | |
| 105 | + | ||
| 85 | 106 | /** Each line of `text` as HTML, coloured. */ | |
| 86 | 107 | export function linesToHtml(core: HighlighterCore, text: string, lang: string): string[] { | |
| 87 | − | const { tokens } = core.codeToTokens(text, { lang, theme: THEME }); | |
| 88 | − | return tokens.map((row) => | |
| 108 | + | return tokenRows(core, text, lang).map((row) => | |
| 89 | 109 | row | |
| 90 | 110 | .map((token) => | |
| 91 | 111 | token.color ? `<span style="color:${token.color}">${escape(token.content)}</span>` : escape(token.content), | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { DEFAULT_THEME, THEME_CHOICES, THEME_COOKIE, readTheme, resolvedTheme, themeAttribute, themeCookie } from "./theme.ts"; | |
| 5 | + | ||
| 6 | + | test("the cookie keeps auto, light or dark, and anything else is the default", () => { | |
| 7 | + | for (const choice of THEME_CHOICES) assert.equal(readTheme(choice), choice); | |
| 8 | + | for (const junk of [null, undefined, "", "LIGHT", "sepia", "dark; Path=/"]) assert.equal(readTheme(junk), DEFAULT_THEME); | |
| 9 | + | }); | |
| 10 | + | ||
| 11 | + | test("the cookie is kept a year, on every path, and secure on https", () => { | |
| 12 | + | assert.equal(themeCookie("light", true), `${THEME_COOKIE}=light; Path=/; Max-Age=31536000; SameSite=Lax; Secure`); | |
| 13 | + | assert.equal(themeCookie("auto", false), `${THEME_COOKIE}=auto; Path=/; Max-Age=31536000; SameSite=Lax`); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("Light and Dark are data-theme on <html>; Auto leaves it to the system", () => { | |
| 17 | + | assert.equal(themeAttribute("light"), "light"); | |
| 18 | + | assert.equal(themeAttribute("dark"), "dark"); | |
| 19 | + | assert.equal(themeAttribute("auto"), undefined); | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | test("a chosen theme is itself; Auto with no browser to ask is dark", () => { | |
| 23 | + | assert.equal(resolvedTheme("light"), "light"); | |
| 24 | + | assert.equal(resolvedTheme("dark"), "dark"); | |
| 25 | + | assert.equal(resolvedTheme("auto"), "dark"); | |
| 26 | + | }); |
| 1 | + | import { useSyncExternalStore } from "react"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * Appearance: Auto, Light or Dark. Each person's choice is kept in a | |
| 5 | + | * cookie (`g1t_theme`), so the server draws the page in it from the first | |
| 6 | + | * byte, with no flash of the other theme. Light and Dark are data-theme on | |
| 7 | + | * <html>; Auto sets nothing and the stylesheet follows the system's | |
| 8 | + | * prefers-color-scheme (app.css). Every colour token is a light-dark() | |
| 9 | + | * pair, so the browser picks the palette from the page's color-scheme. | |
| 10 | + | */ | |
| 11 | + | ||
| 12 | + | export type ThemeChoice = "auto" | "light" | "dark"; | |
| 13 | + | ||
| 14 | + | export const THEME_CHOICES: ThemeChoice[] = ["auto", "light", "dark"]; | |
| 15 | + | ||
| 16 | + | /** The cookie that keeps the choice. */ | |
| 17 | + | export const THEME_COOKIE = "g1t_theme"; | |
| 18 | + | ||
| 19 | + | /** What a page is drawn in before anyone chooses: g1t's own dark. */ | |
| 20 | + | export const DEFAULT_THEME: ThemeChoice = "dark"; | |
| 21 | + | ||
| 22 | + | /** The browser bar's colour in each theme: the page's background. */ | |
| 23 | + | export const THEME_COLOR = { light: "#fbfbfa", dark: "#0f0f11" } as const; | |
| 24 | + | ||
| 25 | + | /** The choice in the cookie's value; the default for anything else. */ | |
| 26 | + | export function readTheme(value: string | null | undefined): ThemeChoice { | |
| 27 | + | return value === "light" || value === "dark" || value === "auto" ? value : DEFAULT_THEME; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** The Set-Cookie value that keeps the choice, for a year. */ | |
| 31 | + | export function themeCookie(choice: ThemeChoice, secure: boolean): string { | |
| 32 | + | return `${THEME_COOKIE}=${choice}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** What <html> carries for a choice: data-theme for Light and Dark, nothing for Auto. */ | |
| 36 | + | export function themeAttribute(choice: ThemeChoice): "light" | "dark" | undefined { | |
| 37 | + | return choice === "auto" ? undefined : choice; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** The words for a choice, as the switch and the command palette say them. */ | |
| 41 | + | export const THEME_LABEL: Record<ThemeChoice, string> = { auto: "Auto", light: "Light", dark: "Dark" }; | |
| 42 | + | ||
| 43 | + | // The choice made in this tab since the page loaded; null until one is. | |
| 44 | + | // Only ever set in the browser: on the server the page's own cookie rules. | |
| 45 | + | let chosen: ThemeChoice | null = null; | |
| 46 | + | const listeners = new Set<() => void>(); | |
| 47 | + | ||
| 48 | + | function subscribe(listener: () => void) { | |
| 49 | + | listeners.add(listener); | |
| 50 | + | return () => listeners.delete(listener); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** | |
| 54 | + | * The person's choice: the one made in this tab, else the one the page was | |
| 55 | + | * drawn with (the root loader's, from the cookie). | |
| 56 | + | */ | |
| 57 | + | export function useThemeChoice(drawn: ThemeChoice | undefined): ThemeChoice { | |
| 58 | + | const initial = drawn ?? DEFAULT_THEME; | |
| 59 | + | return useSyncExternalStore( | |
| 60 | + | subscribe, | |
| 61 | + | () => chosen ?? initial, | |
| 62 | + | () => initial, | |
| 63 | + | ); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /** | |
| 67 | + | * Switch to `choice` at once and remember it: <html>'s data-theme and the | |
| 68 | + | * browser bar's colour change now, the cookie keeps it for the next page | |
| 69 | + | * the server draws, and every switch on the page follows. | |
| 70 | + | */ | |
| 71 | + | export function setTheme(choice: ThemeChoice) { | |
| 72 | + | chosen = choice; | |
| 73 | + | if (typeof document !== "undefined") { | |
| 74 | + | const root = document.documentElement; | |
| 75 | + | const attribute = themeAttribute(choice); | |
| 76 | + | if (attribute) root.dataset.theme = attribute; | |
| 77 | + | else delete root.dataset.theme; | |
| 78 | + | document.cookie = themeCookie(choice, window.location.protocol === "https:"); | |
| 79 | + | } | |
| 80 | + | for (const listener of listeners) listener(); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** The theme a choice comes to on this screen now: Auto asks the system. */ | |
| 84 | + | export function resolvedTheme(choice: ThemeChoice): "light" | "dark" { | |
| 85 | + | if (choice !== "auto") return choice; | |
| 86 | + | if (typeof window === "undefined" || !window.matchMedia) return "dark"; | |
| 87 | + | return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /** The theme drawn right now, from <html>, for code that colours outside CSS (mermaid, the editor). */ | |
| 91 | + | export function currentTheme(): "light" | "dark" { | |
| 92 | + | if (typeof document === "undefined") return "dark"; | |
| 93 | + | const attribute = document.documentElement.dataset.theme; | |
| 94 | + | return attribute === "light" || attribute === "dark" ? attribute : resolvedTheme("auto"); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | function subscribeDrawn(listener: () => void) { | |
| 98 | + | const unsubscribe = subscribe(listener); | |
| 99 | + | const media = typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(prefers-color-scheme: light)") : null; | |
| 100 | + | media?.addEventListener("change", listener); | |
| 101 | + | return () => { | |
| 102 | + | unsubscribe(); | |
| 103 | + | media?.removeEventListener("change", listener); | |
| 104 | + | }; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /** | |
| 108 | + | * The theme drawn right now, Light or Dark, kept current as it is switched | |
| 109 | + | * or the system changes under Auto. Needs no router, so it works inside | |
| 110 | + | * the editor's own blocks. The server says Dark; the browser corrects it. | |
| 111 | + | */ | |
| 112 | + | export function useDrawnTheme(): "light" | "dark" { | |
| 113 | + | return useSyncExternalStore(subscribeDrawn, currentTheme, () => "dark"); | |
| 114 | + | } |
| 52 | 52 | test("focus borders in the dim accent stand out from the page", () => { | |
| 53 | 53 | assert.ok(contrast(token("accent-dim"), token("bg")) >= 3); | |
| 54 | 54 | }); | |
| 55 | + | ||
| 56 | + | // The light palette (Appearance, lib/theme.ts): the same names after `light-`. | |
| 57 | + | ||
| 58 | + | test("the light accent is a deeper lavender, and light success is green", () => { | |
| 59 | + | assert.equal(token("light-accent"), "#6b52c8"); | |
| 60 | + | assert.equal(token("light-success"), "#0d7a52"); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | test("every dark token has a light one", () => { | |
| 64 | + | const dark = [...css.matchAll(/--g1t-(?!light-|font-)([a-z-]+):/g)].map((match) => match[1]); | |
| 65 | + | assert.ok(dark.length > 10); | |
| 66 | + | for (const name of dark) token(`light-${name}`); | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | test("light text colours meet WCAG AA on every light surface", () => { | |
| 70 | + | for (const surface of ["bg", "surface", "raised"]) { | |
| 71 | + | for (const ink of ["fg", "fg-soft", "muted", "faint", "accent", "accent-hover", "success", "merged", "info", "warn", "danger"]) { | |
| 72 | + | const ratio = contrast(token(`light-${ink}`), token(`light-${surface}`)); | |
| 73 | + | assert.ok(ratio >= 4.5, `--g1t-light-${ink} on --g1t-light-${surface} is ${ratio.toFixed(2)}:1`); | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | test("the light page reads on an accent or success button", () => { | |
| 79 | + | for (const fill of ["accent", "accent-hover", "success"]) { | |
| 80 | + | const ratio = contrast(token("light-bg"), token(`light-${fill}`)); | |
| 81 | + | assert.ok(ratio >= 4.5, `--g1t-light-bg on --g1t-light-${fill} is ${ratio.toFixed(2)}:1`); | |
| 82 | + | } | |
| 83 | + | }); | |
| 84 | + | ||
| 85 | + | test("light focus borders in the dim accent stand out from the page", () => { | |
| 86 | + | assert.ok(contrast(token("light-accent-dim"), token("light-bg")) >= 3); | |
| 87 | + | }); |
| 50 | 50 | import { isNeedsSignIn } from "./lib/website-token"; | |
| 51 | 51 | import { setLiveViaToken } from "./lib/live-socket"; | |
| 52 | 52 | import { LiveNotifications } from "./components/notifications/live-notifications"; | |
| 53 | + | import { THEME_COLOR, THEME_COOKIE, readTheme, themeAttribute, useThemeChoice } from "./lib/theme"; | |
| 53 | 54 | ||
| 54 | 55 | ||
| 55 | 56 | export const links: Route.LinksFunction = () => [ | |
| ⋯ | |||
| 101 | 102 | shell, | |
| 102 | 103 | // Whether they folded the sidebar away, so the page is drawn that way from the start. | |
| 103 | 104 | sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)), | |
| 105 | + | // Their Appearance, so the page is drawn in it from the first byte (lib/theme.ts). | |
| 106 | + | theme: readTheme(readCookie(cookies, THEME_COOKIE)), | |
| 104 | 107 | inviteOnly: mode !== "open", | |
| 105 | 108 | addresses: addresses(), | |
| 106 | 109 | // Visitors from where the law asks first are asked before analytics runs. | |
| ⋯ | |||
| 368 | 371 | // has not turned it on: they keep their place, and cannot use it yet. | |
| 369 | 372 | const held = user?.held && user.held.length > 0 && pathname !== "/settings/two-factor" && <PolicyNotice held={user.held} />; | |
| 370 | 373 | const leaving = useLeaving(); | |
| 374 | + | // Auto, Light or Dark: as the cookie said when the page was drawn, or as just switched. | |
| 375 | + | const theme = useThemeChoice(root?.theme); | |
| 371 | 376 | // Which frame the page is drawn in (lib/chrome.ts): on its own, the app's, or the public one. | |
| 372 | 377 | const frame = frameOf(pathname, { | |
| 373 | 378 | signedIn: user != null && !awaitsConfirmation(user), | |
| ⋯ | |||
| 382 | 387 | </> | |
| 383 | 388 | ) : null; | |
| 384 | 389 | return ( | |
| 385 | − | <html lang="en"> | |
| 390 | + | <html lang="en" data-theme={themeAttribute(theme)}> | |
| 386 | 391 | <head> | |
| 387 | 392 | <meta charSet="utf-8" /> | |
| 388 | 393 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> | |
| 389 | − | <meta name="theme-color" content="#0f0f11" /> | |
| 394 | + | {/* The browser bar matches the page: one colour for a chosen theme, the system's for Auto. */} | |
| 395 | + | {theme === "auto" ? ( | |
| 396 | + | <> | |
| 397 | + | <meta name="theme-color" media="(prefers-color-scheme: light)" content={THEME_COLOR.light} /> | |
| 398 | + | <meta name="theme-color" media="(prefers-color-scheme: dark)" content={THEME_COLOR.dark} /> | |
| 399 | + | </> | |
| 400 | + | ) : ( | |
| 401 | + | <meta name="theme-color" content={THEME_COLOR[theme]} /> | |
| 402 | + | )} | |
| 390 | 403 | <meta name="mobile-web-app-capable" content="yes" /> | |
| 391 | 404 | <meta name="apple-mobile-web-app-capable" content="yes" /> | |
| 392 | 405 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> | |
| 124 | 124 | <div className="mt-4"> | |
| 125 | 125 | <a | |
| 126 | 126 | href={`/auth/github?link=1&next=${encodeURIComponent(here)}`} | |
| 127 | − | className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white" | |
| 127 | + | className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover" | |
| 128 | 128 | > | |
| 129 | 129 | <GithubMark /> Connect GitHub | |
| 130 | 130 | </a> | |
| ⋯ | |||
| 157 | 157 | <div className="mt-4"> | |
| 158 | 158 | <a | |
| 159 | 159 | href={`/integrations/github/install?workspace=${workspace}`} | |
| 160 | − | className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white" | |
| 160 | + | className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover" | |
| 161 | 161 | > | |
| 162 | 162 | <GithubMark /> Install on GitHub | |
| 163 | 163 | </a> | |
| 1084 | 1084 | <Quiet | |
| 1085 | 1085 | action={ | |
| 1086 | 1086 | member ? ( | |
| 1087 | − | <Link to={`${base}/issues/new`} className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-xs font-medium text-bg hover:bg-white"> | |
| 1087 | + | <Link to={`${base}/issues/new`} className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-xs font-medium text-bg hover:bg-fg-hover"> | |
| 1088 | 1088 | <Bot size={12} /> Open an issue for an agent | |
| 1089 | 1089 | </Link> | |
| 1090 | 1090 | ) : null |
| 59 | 59 | actions={ | |
| 60 | 60 | scanning.entitled && can.manage_settings && !scanning.configured ? ( | |
| 61 | 61 | <Form method="post" action={`${base}/security/code-scanning/setup`}> | |
| 62 | − | <button type="submit" disabled={settingUp} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"> | |
| 62 | + | <button type="submit" disabled={settingUp} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50"> | |
| 63 | 63 | {settingUp ? "Opening a pull request…" : "Set up code scanning"} | |
| 64 | 64 | </button> | |
| 65 | 65 | </Form> |
| 135 | 135 | <button | |
| 136 | 136 | type="submit" | |
| 137 | 137 | disabled={disabled || fetcher.state !== "idle"} | |
| 138 | − | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 138 | + | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50" | |
| 139 | 139 | > | |
| 140 | 140 | {fetcher.state !== "idle" ? "Saving…" : "Save"} | |
| 141 | 141 | </button> |
| 3 | 3 | import type { Route } from "./+types/account"; | |
| 4 | 4 | import { DeleteAccountAction } from "../../components/delete-account"; | |
| 5 | 5 | import { DangerZone } from "../../components/danger-zone"; | |
| 6 | + | import { ThemeSwitch } from "../../components/theme-switch"; | |
| 6 | 7 | import { githubSignIn } from "../../lib/github.server"; | |
| 7 | 8 | import { page } from "../../lib/meta"; | |
| 8 | 9 | import { accounts } from "../../lib/services.server"; | |
| ⋯ | |||
| 63 | 64 | . | |
| 64 | 65 | </p> | |
| 65 | 66 | </section> | |
| 67 | + | <section aria-labelledby="appearance-heading"> | |
| 68 | + | <h2 id="appearance-heading" className="font-medium"> | |
| 69 | + | Appearance | |
| 70 | + | </h2> | |
| 71 | + | <p className="mt-1 text-sm text-muted"> | |
| 72 | + | Light, dark, or Auto to follow your system. This browser remembers it. | |
| 73 | + | </p> | |
| 74 | + | <ThemeSwitch className="mt-3 w-full max-w-xs" /> | |
| 75 | + | </section> | |
| 66 | 76 | <DangerZone> | |
| 67 | 77 | <DeleteAccountAction | |
| 68 | 78 | username={username} | |
| 61 | 61 | </button> | |
| 62 | 62 | ) : ( | |
| 63 | 63 | <> | |
| 64 | − | <button type="submit" name="decision" value="approve" disabled={busy} className="rounded-md bg-fg px-3 py-1 text-xs font-medium text-bg hover:bg-white disabled:opacity-50"> | |
| 64 | + | <button type="submit" name="decision" value="approve" disabled={busy} className="rounded-md bg-fg px-3 py-1 text-xs font-medium text-bg hover:bg-fg-hover disabled:opacity-50"> | |
| 65 | 65 | Approve | |
| 66 | 66 | </button> | |
| 67 | 67 | <button type="submit" name="decision" value="deny" disabled={busy} className="rounded-md border border-danger/40 px-3 py-1 text-xs font-medium text-danger hover:bg-danger/10 disabled:opacity-50"> |
| 73 | 73 | disabled={disabled} | |
| 74 | 74 | /> | |
| 75 | 75 | <div className="flex items-center gap-3 pt-1"> | |
| 76 | − | <button type="submit" disabled={disabled || fetcher.state !== "idle"} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"> | |
| 76 | + | <button type="submit" disabled={disabled || fetcher.state !== "idle"} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover disabled:opacity-50"> | |
| 77 | 77 | {fetcher.state !== "idle" ? "Saving…" : "Save"} | |
| 78 | 78 | </button> | |
| 79 | 79 | {!owner && <span className="text-sm text-muted">Only an owner can change these.</span>} |
| 47 | 47 | --g1t-merged: #a678f5; | |
| 48 | 48 | --g1t-warn: #ffbd8c; | |
| 49 | 49 | --g1t-danger: #ff8394; | |
| 50 | + | ||
| 51 | + | /* | |
| 52 | + | * The light palette, for a site that offers one (the app's Appearance | |
| 53 | + | * setting; apps/web/app/app.css picks between the two with | |
| 54 | + | * light-dark()). The same names with `light-` in front. A near-white | |
| 55 | + | * page, white panels, and a deeper lavender so the accent still reads | |
| 56 | + | * as text: every text colour here is at least 4.5:1 on bg, surface | |
| 57 | + | * and raised (apps/web/app/lib/tokens.test.ts). | |
| 58 | + | */ | |
| 59 | + | --g1t-light-bg: #fbfbfa; | |
| 60 | + | --g1t-light-surface: #ffffff; | |
| 61 | + | --g1t-light-raised: #f1f1ef; | |
| 62 | + | --g1t-light-line: #e4e4e1; | |
| 63 | + | --g1t-light-line-strong: #d2d2ce; | |
| 64 | + | ||
| 65 | + | --g1t-light-fg: #18181b; | |
| 66 | + | --g1t-light-fg-soft: #2e2e33; | |
| 67 | + | --g1t-light-muted: #5c5c66; | |
| 68 | + | --g1t-light-faint: #686871; | |
| 69 | + | ||
| 70 | + | --g1t-light-accent: #6b52c8; | |
| 71 | + | --g1t-light-accent-hover: #5b42b8; | |
| 72 | + | --g1t-light-accent-dim: #9584dc; | |
| 73 | + | --g1t-light-accent-low: #ece8fb; | |
| 74 | + | ||
| 75 | + | --g1t-light-success: #0d7a52; | |
| 76 | + | --g1t-light-success-dim: #3a9d77; | |
| 77 | + | --g1t-light-success-low: #e2f4ec; | |
| 78 | + | ||
| 79 | + | --g1t-light-info: #2160cc; | |
| 80 | + | --g1t-light-merged: #7c3fd1; | |
| 81 | + | --g1t-light-warn: #a84f00; | |
| 82 | + | --g1t-light-danger: #c42a43; | |
| 50 | 83 | } |