@robr0/design-system 0.6.0 → 0.7.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/LICENSE +53 -2
- package/README.md +36 -5
- package/components/AgentStatus/AgentStatus.css +26 -11
- package/components/Card/Card.css +9 -0
- package/components/Card/Card.d.ts +6 -0
- package/components/Card/Card.js +2 -1
- package/components/Composer/Composer.css +18 -0
- package/components/Composer/Composer.d.ts +3 -0
- package/components/Composer/Composer.js +12 -1
- package/components/Prose/Prose.css +18 -1
- package/components/Reasoning/Reasoning.css +19 -4
- package/components/ShaderField/ShaderField.css +24 -0
- package/components/ShaderField/ShaderField.d.ts +68 -0
- package/components/ShaderField/ShaderField.js +54 -0
- package/components/ShaderField/field.glsl.d.ts +33 -0
- package/components/ShaderField/field.glsl.js +177 -0
- package/components/ShaderField/useShaderField.d.ts +86 -0
- package/components/ShaderField/useShaderField.js +395 -0
- package/components/registry.json +8 -0
- package/components/registry.json.d.ts +8 -0
- package/components/registry.json.js +1 -1
- package/index.d.ts +1 -0
- package/index.js +8 -0
- package/package.json +5 -1
- package/tokens/registry.json +1 -0
- package/tokens/registry.json.d.ts +1 -0
- package/tokens/registry.json.js +1 -1
- package/tokens/tokens-dark.css +1 -1
- package/tokens/tokens-light.css +2 -2
- package/tokens/tokens-motion.css +2 -0
package/LICENSE
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
Copyright (c) 2026 Rob Ritacca. All rights reserved, except as granted below.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This repository holds two kinds of material under two different licenses.
|
|
4
|
+
The SOFTWARE is free to use under the MIT License. The CONTENT is not
|
|
5
|
+
licensed for reuse.
|
|
6
|
+
|
|
7
|
+
If you installed this as an npm package, you received the Software only.
|
|
8
|
+
The MIT License below is the license for everything in that package.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
================================================================================
|
|
12
|
+
SOFTWARE — MIT License
|
|
13
|
+
================================================================================
|
|
14
|
+
|
|
15
|
+
Applies to the design system and the code that builds and documents it:
|
|
16
|
+
component source and CSS, the design tokens, Storybook stories, the build,
|
|
17
|
+
generator and validator scripts, configuration, and the website's application
|
|
18
|
+
code (layouts, routing, hooks, and UI components).
|
|
4
19
|
|
|
5
20
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
21
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -19,3 +34,39 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
19
34
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
35
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
36
|
SOFTWARE.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
================================================================================
|
|
40
|
+
CONTENT — All rights reserved
|
|
41
|
+
================================================================================
|
|
42
|
+
|
|
43
|
+
Applies to the authored material published on robertritacca.com and stored in
|
|
44
|
+
this repository, including:
|
|
45
|
+
|
|
46
|
+
- Written prose on any page: body copy, headings, descriptions, and
|
|
47
|
+
microcopy across the website and the root specification documents.
|
|
48
|
+
- Case studies and the client work they describe
|
|
49
|
+
(website/src/app/work/, website/src/data/case-studies.json).
|
|
50
|
+
- Essays and long-form writing
|
|
51
|
+
(website/src/app/writing/, website/src/data/essays.json).
|
|
52
|
+
- The project journal entries (website/src/data/site-updates.json).
|
|
53
|
+
- Photographs, illustrations, client logos, cover images, and other media.
|
|
54
|
+
- The name "Rob Ritacca", the RR logo and wordmark, and any likeness,
|
|
55
|
+
photograph, biography, or professional history of Rob Ritacca.
|
|
56
|
+
|
|
57
|
+
No license is granted to this material. Without prior written permission you
|
|
58
|
+
may not copy, reproduce, republish, mirror, redistribute, sell, or create
|
|
59
|
+
derivative works from it, and you may not use it to train, fine-tune, or
|
|
60
|
+
otherwise build a machine learning model. You may not present it as your own
|
|
61
|
+
work, and you may not use the name, logo, likeness, or professional history of
|
|
62
|
+
Rob Ritacca to imply authorship, endorsement, or affiliation.
|
|
63
|
+
|
|
64
|
+
You are welcome to read the site, quote short passages with attribution and a
|
|
65
|
+
link to the source, link to any page, and use automated tools to read and cite
|
|
66
|
+
pages as a human reader would. Nothing here restricts rights you already have
|
|
67
|
+
under applicable copyright law.
|
|
68
|
+
|
|
69
|
+
Client logos and trademarks shown in case studies belong to their respective
|
|
70
|
+
owners and appear as references to real work, not as a grant of any rights.
|
|
71
|
+
|
|
72
|
+
Permission requests: rob.ritacca@gmail.com
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@robr0/design-system)
|
|
6
6
|
<!-- npm-badge:end -->
|
|
7
7
|
|
|
8
|
-
A personal portfolio site, the AI-ready React design system behind it, and the AI layer that lets the site answer questions about itself. Claude Code builds all of it from the written specs in this repo (`CLAUDE.md` for the rules, `design.md` for the design language, `content-design.md` for how every word reads
|
|
8
|
+
A personal portfolio site, the AI-ready React design system behind it, and the AI layer that lets the site answer questions about itself. Claude Code builds all of it from the written specs in this repo (`CLAUDE.md` for the rules, `design.md` for the design language, `content-design.md` for how every word reads), and generated registries keep this README and the docs site from drifting. The design system is the backbone every portfolio page is built on, so the work is presented consistently and to the same craft standards throughout.
|
|
9
9
|
|
|
10
10
|
**[→ Live site](https://robertritacca.com/)** · **[→ Storybook](https://design-system-iota-one.vercel.app/?path=/docs/robr0-ds--docs)**
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ The **live site** is the portfolio built on the design system. **Storybook** is
|
|
|
18
18
|
| Part | Description |
|
|
19
19
|
|---|---|
|
|
20
20
|
| **Portfolio website** (`/website`) | Next.js app with case studies, work history, writing, and about pages, all built exclusively with the design system components below. **[Live site →](https://robertritacca.com/)** |
|
|
21
|
-
| **Design system** (`/src`) | <!-- component-count -->
|
|
21
|
+
| **Design system** (`/src`) | <!-- component-count -->79<!-- /component-count --> React components, a three-tier token architecture, dark mode, a WebGL2 ambient background that themes itself from your tokens, and a full documentation site. Built to production standards. **[Storybook →](https://design-system-iota-one.vercel.app/?path=/docs/robr0-ds--docs)** |
|
|
22
22
|
| **AI layer** (`ai` components in `/src`, chat in `/website`, `/evals`) | A site-wide chat that answers questions about the work: built from the library's own `ai` components, grounded in a corpus generated from the site's published content, and scored by a golden-set eval. |
|
|
23
23
|
|
|
24
24
|
---
|
|
@@ -28,9 +28,32 @@ The **live site** is the portfolio built on the design system. **Storybook** is
|
|
|
28
28
|
### Components
|
|
29
29
|
|
|
30
30
|
<!-- component-list:start -->
|
|
31
|
-
Accordion · Agent status · AI button · Alert · Alert dialog · App layout · App sidebar · Avatar · Badge · Breadcrumb · Button · Button group · Card · Carousel · Chart · Chat header · Chat marker · Chat message · Chat thread · Checkbox · Chip · Circular button · Code block · Colour picker · Combobox · Command palette · Composer · Contact card · Context menu · Contribution graph · Date input · Date picker · Dialog · Divider · Document chip · Drawer · Dropdown · Dropdown menu · Empty state · Entity card · Field · Figure · File input · Input · Instructions · Interrupt card · Kbd · Link list · Message actions · Message card · Navigation · Nav list · Pagination · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radio button · Reasoning · Section title · Segmented control · Selection card · Skeleton · Slider · Source chip · Spinner · Stat · Swatch · Table · Tabs · Textarea · Timeline · Toast · Toggle group · Toggle switch · Tool call · Tooltip
|
|
31
|
+
Accordion · Agent status · AI button · Alert · Alert dialog · App layout · App sidebar · Avatar · Badge · Breadcrumb · Button · Button group · Card · Carousel · Chart · Chat header · Chat marker · Chat message · Chat thread · Checkbox · Chip · Circular button · Code block · Colour picker · Combobox · Command palette · Composer · Contact card · Context menu · Contribution graph · Date input · Date picker · Dialog · Divider · Document chip · Drawer · Dropdown · Dropdown menu · Empty state · Entity card · Field · Figure · File input · Input · Instructions · Interrupt card · Kbd · Link list · Message actions · Message card · Navigation · Nav list · Pagination · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radio button · Reasoning · Section title · Segmented control · Selection card · Shader field · Skeleton · Slider · Source chip · Spinner · Stat · Swatch · Table · Tabs · Textarea · Timeline · Toast · Toggle group · Toggle switch · Tool call · Tooltip
|
|
32
32
|
<!-- component-list:end -->
|
|
33
33
|
|
|
34
|
+
### Ambient background
|
|
35
|
+
|
|
36
|
+
The package ships more than components. **Shader field** is a WebGL2 canvas that sums soft Gaussian light sources into an ambient field of colour, and every source reads a semantic colour token at runtime. Override a primitive and the background re-themes with the rest of the system, in both themes, with nothing wired up:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { ShaderField, type ShaderFieldStatus } from '@robr0/design-system';
|
|
40
|
+
|
|
41
|
+
const [status, setStatus] = useState<ShaderFieldStatus>('pending');
|
|
42
|
+
|
|
43
|
+
<div style={{ position: 'fixed', inset: 0, zIndex: -1 }}>
|
|
44
|
+
{status === 'unavailable' && <YourCssFallback />}
|
|
45
|
+
<ShaderField params={{ streak: 0.4 }} onStatusChange={setStatus} />
|
|
46
|
+
</div>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Seven parameters describe the look, and a composition is a table of token names with positions and drift periods, so a negative weight cuts a shadow through the field instead of adding light to it.
|
|
50
|
+
|
|
51
|
+
It asks two things of you, both deliberate. It fills a positioned ancestor you provide, because where a background sits is a layout decision rather than a rendering one. And it never decides what to paint instead of itself: it reports `pending`, `active` or `unavailable`, and one fallback covers every way it can fail (no WebGL2, a blocked or lost GPU context, a renderer that stalls before its first frame, or `enabled={false}` as a kill switch). `pending` is the state worth handling. Paint neither layer while the context comes up, or the swap a frame later reads as two backgrounds loading in sequence.
|
|
52
|
+
|
|
53
|
+
It is also the one place a `prefers-reduced-motion` check lives in JavaScript rather than in the motion tokens, because the CSS guard cannot see a `requestAnimationFrame` loop. Set the preference and it draws a single static frame.
|
|
54
|
+
|
|
55
|
+
The background behind this site is that component, with eight blurred CSS discs kept painted underneath as its fallback. The **[component page](https://robertritacca.com/components/shader-field)** is a live demo, and the **[setup guide](https://robertritacca.com/docs/get-started)** has the wiring.
|
|
56
|
+
|
|
34
57
|
### Using the package
|
|
35
58
|
|
|
36
59
|
The design system is published as [`@robr0/design-system`](https://www.npmjs.com/package/@robr0/design-system) (React 19+ is a peer dependency). The **[full setup guide](https://robertritacca.com/docs/get-started)** expands on everything in this section:
|
|
@@ -116,14 +139,14 @@ Answers are measured, not assumed. `evals/chat` holds a golden set that runs thr
|
|
|
116
139
|
- **Vite 7**: dev server and library build
|
|
117
140
|
- **Next.js 16**: portfolio site and design system documentation
|
|
118
141
|
- **Storybook 10**: component explorer
|
|
119
|
-
- **Vitest + Playwright**: every Storybook story runs as a render test in headless Chromium
|
|
142
|
+
- **Vitest + Playwright + axe**: every Storybook story runs as a render test in headless Chromium, with an accessibility audit on each
|
|
120
143
|
- **CSS custom properties**: all theming via semantic tokens, no CSS-in-JS
|
|
121
144
|
|
|
122
145
|
---
|
|
123
146
|
|
|
124
147
|
## Quality & CI
|
|
125
148
|
|
|
126
|
-
Every push and pull request runs a GitHub Actions pipeline ([`ci.yml`](.github/workflows/ci.yml)) with four jobs: lint + library build, story tests
|
|
149
|
+
Every push and pull request runs a GitHub Actions pipeline ([`ci.yml`](.github/workflows/ci.yml)) with four jobs: lint + library build, story tests, Storybook build, and website lint + build. The story tests render every Storybook story in headless Chromium via Vitest and run an axe accessibility audit on each, so a violation fails the build exactly like a render error. The same checklist runs locally with one command:
|
|
127
150
|
|
|
128
151
|
```bash
|
|
129
152
|
npm run verify # lint + library type-check + package build + story tests + Storybook build + website lint + build
|
|
@@ -143,3 +166,11 @@ npm run storybook # http://localhost:6006
|
|
|
143
166
|
npm install
|
|
144
167
|
npm run dev --workspace website # http://localhost:3000
|
|
145
168
|
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
Two licenses, one repository. The **software** (components, tokens, scripts, and the website's application code) is MIT, so use it in anything. The **content** (page prose, case studies, essays, images, and the name and likeness of Rob Ritacca) is all rights reserved and not licensed for reuse. See [`LICENSE`](LICENSE) for the full terms.
|
|
175
|
+
|
|
176
|
+
The npm package contains the software only.
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
.ds-agent-status {
|
|
23
23
|
--ds-agent-color: var(--color-text-secondary);
|
|
24
24
|
--ds-agent-label-color: var(--color-text-secondary);
|
|
25
|
-
--ds-agent-shimmer-base: var(--color-text-
|
|
26
|
-
--ds-agent-shimmer-sweep: var(--color-text-
|
|
25
|
+
--ds-agent-shimmer-base: var(--color-text-tertiary);
|
|
26
|
+
--ds-agent-shimmer-sweep: var(--color-text-primary);
|
|
27
27
|
--ds-agent-dot-dim: 0.2;
|
|
28
28
|
--ds-agent-dot-rest: 0.82;
|
|
29
29
|
--ds-agent-cycle: var(--motion-duration-loop-matrix);
|
|
@@ -33,11 +33,16 @@
|
|
|
33
33
|
display: inline-flex;
|
|
34
34
|
align-items: center;
|
|
35
35
|
gap: var(--gap-sm-md);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
|
|
37
|
+
/* The label is set in the title-body face, not the paragraph one: a status
|
|
38
|
+
is a heading for the work underneath it, not prose. The two sets share
|
|
39
|
+
16px/24px metrics, so the row's geometry is identical either way — only
|
|
40
|
+
the weight and tracking change. */
|
|
41
|
+
font-family: var(--font-title-body-family);
|
|
42
|
+
font-size: var(--font-title-body-size);
|
|
43
|
+
font-weight: var(--font-title-body-weight);
|
|
44
|
+
line-height: var(--font-title-body-line-height);
|
|
45
|
+
letter-spacing: var(--font-title-body-letter-spacing);
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
/* ============================================
|
|
@@ -112,13 +117,19 @@
|
|
|
112
117
|
/* A gradient between the shimmer base and sweep colours, moving left to
|
|
113
118
|
right: with an oversized background image, background-position 100% shows
|
|
114
119
|
its right edge, 0% its left, so counting down walks the sweep band across
|
|
115
|
-
the text.
|
|
120
|
+
the text.
|
|
121
|
+
|
|
122
|
+
The base is the dimmer colour and the sweep the brighter one, so what
|
|
123
|
+
travels is a highlight rather than a shadow — the same "the sweep only
|
|
124
|
+
brightens it" reading `waiting` has always had. The band is narrow
|
|
125
|
+
(12% of a 250% image, so under a third of the text at a time): spread
|
|
126
|
+
wide it washes the whole label at once and stops reading as movement. */
|
|
116
127
|
.ds-agent-status__label--shimmer {
|
|
117
128
|
background-image: linear-gradient(
|
|
118
129
|
100deg,
|
|
119
|
-
var(--ds-agent-shimmer-base)
|
|
130
|
+
var(--ds-agent-shimmer-base) 44%,
|
|
120
131
|
var(--ds-agent-shimmer-sweep) 50%,
|
|
121
|
-
var(--ds-agent-shimmer-base)
|
|
132
|
+
var(--ds-agent-shimmer-base) 56%
|
|
122
133
|
);
|
|
123
134
|
background-size: 250% 100%;
|
|
124
135
|
background-clip: text;
|
|
@@ -189,9 +200,13 @@
|
|
|
189
200
|
--ds-agent-matrix-size: var(--icon-size-sm);
|
|
190
201
|
|
|
191
202
|
gap: var(--gap-sm);
|
|
203
|
+
|
|
204
|
+
/* The small metrics, carrying the same title weight down a size: the scale
|
|
205
|
+
has no 14px title face, and dropping to the paragraph weight here would
|
|
206
|
+
make one size of the component read as a heading and the other as prose. */
|
|
192
207
|
font-family: var(--font-paragraph-sm-family);
|
|
193
208
|
font-size: var(--font-paragraph-sm-size);
|
|
194
|
-
font-weight: var(--font-
|
|
209
|
+
font-weight: var(--font-title-body-weight);
|
|
195
210
|
line-height: var(--font-paragraph-sm-line-height);
|
|
196
211
|
letter-spacing: var(--font-paragraph-sm-letter-spacing);
|
|
197
212
|
}
|
package/components/Card/Card.css
CHANGED
|
@@ -106,6 +106,15 @@
|
|
|
106
106
|
background: var(--color-bg-container-secondary);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/* A drawn cover (the `cover` prop) fills the same slot the image would, so a
|
|
110
|
+
grid of cards keeps one cover shape whichever kind it was given. */
|
|
111
|
+
.ds-card--case-study__cover-slot,
|
|
112
|
+
.ds-card--case-study__cover-slot > * {
|
|
113
|
+
display: block;
|
|
114
|
+
width: 100%;
|
|
115
|
+
height: 100%;
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
.ds-card--case-study__cover-image {
|
|
110
119
|
width: 100%;
|
|
111
120
|
height: 100%;
|
|
@@ -18,6 +18,12 @@ type CardOwnProps = {
|
|
|
18
18
|
href?: string;
|
|
19
19
|
/** Cover image src */
|
|
20
20
|
coverSrc?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Cover content rendered in place of the image — for a cover that is drawn
|
|
23
|
+
* rather than photographed (an SVG, a chart, a live preview). Takes
|
|
24
|
+
* precedence over `coverSrc`, and fills the same fixed-ratio slot.
|
|
25
|
+
*/
|
|
26
|
+
cover?: React.ReactNode;
|
|
21
27
|
/** Cover image alt text */
|
|
22
28
|
coverAlt?: string;
|
|
23
29
|
/** Path to the company logo shown in the eyebrow */
|
package/components/Card/Card.js
CHANGED
|
@@ -13,6 +13,7 @@ const Card = React.forwardRef(
|
|
|
13
13
|
className = "",
|
|
14
14
|
href,
|
|
15
15
|
coverSrc,
|
|
16
|
+
cover,
|
|
16
17
|
coverAlt,
|
|
17
18
|
companyLogo,
|
|
18
19
|
companyName,
|
|
@@ -22,7 +23,7 @@ const Card = React.forwardRef(
|
|
|
22
23
|
}, ref) => {
|
|
23
24
|
if (variant === "case-study") {
|
|
24
25
|
const inner = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
25
|
-
/* @__PURE__ */ jsx("div", { className: "ds-card--case-study__cover-wrap", children: coverSrc ? /* @__PURE__ */ jsx(
|
|
26
|
+
/* @__PURE__ */ jsx("div", { className: "ds-card--case-study__cover-wrap", children: cover ? /* @__PURE__ */ jsx("div", { className: "ds-card--case-study__cover-slot", children: cover }) : coverSrc ? /* @__PURE__ */ jsx(
|
|
26
27
|
"img",
|
|
27
28
|
{
|
|
28
29
|
src: coverSrc,
|
|
@@ -58,8 +58,26 @@
|
|
|
58
58
|
|
|
59
59
|
.ds-composer__content {
|
|
60
60
|
display: flex;
|
|
61
|
+
/* The textarea keeps its own natural height rather than stretching to the
|
|
62
|
+
zone's — the measurement below reads it, so a stretched textarea would
|
|
63
|
+
feed its own height back in and the zone could never shrink. */
|
|
64
|
+
align-items: flex-start;
|
|
65
|
+
box-sizing: border-box;
|
|
61
66
|
width: 100%;
|
|
62
67
|
padding: var(--padding-md) var(--padding-lg) var(--padding-sm);
|
|
68
|
+
/* Growing and shrinking is animated, which needs two pixel heights to run
|
|
69
|
+
between — `auto` has nothing to interpolate. So the zone states its own
|
|
70
|
+
height: the measured textarea (published as --ds-composer-text-height on
|
|
71
|
+
every resize) plus the block padding it owns. The fallback is a single
|
|
72
|
+
row, which is what the shell renders before the first measurement. */
|
|
73
|
+
height: calc(
|
|
74
|
+
var(--ds-composer-text-height, var(--font-paragraph-line-height)) + var(--padding-md) +
|
|
75
|
+
var(--padding-sm)
|
|
76
|
+
);
|
|
77
|
+
/* Mid-transition the textarea is already at its new height, so the extra
|
|
78
|
+
line is clipped on the way in and released on the way out. */
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
transition: height var(--motion-duration-instant) var(--motion-ease-emphasized);
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
.ds-composer__textarea {
|
|
@@ -59,6 +59,9 @@ export interface ComposerProps extends ComposerOwnProps, Omit<React.ComponentPro
|
|
|
59
59
|
* The textarea grows with its content up to `maxRows`, then scrolls
|
|
60
60
|
* internally. Where the browser supports `field-sizing: content` the sizing
|
|
61
61
|
* is fully native; elsewhere a measurement effect keeps the height in step.
|
|
62
|
+
* Either way the text zone glides between the two heights rather than
|
|
63
|
+
* snapping — 75ms, short enough to soften the step without reading as an
|
|
64
|
+
* animation. The action bar's buttons never move relative to the bar.
|
|
62
65
|
*
|
|
63
66
|
* Forwards a ref to the underlying `<textarea>` and spreads unrecognised
|
|
64
67
|
* props onto it; `className` lands on the shell.
|
|
@@ -23,6 +23,7 @@ const Composer = React.forwardRef(
|
|
|
23
23
|
}, ref) => {
|
|
24
24
|
const baseClass = "ds-composer";
|
|
25
25
|
const textareaRef = useRef(null);
|
|
26
|
+
const contentRef = useRef(null);
|
|
26
27
|
const isControlled = value !== void 0;
|
|
27
28
|
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
|
|
28
29
|
const currentValue = isControlled ? value : uncontrolledValue;
|
|
@@ -40,6 +41,16 @@ const Composer = React.forwardRef(
|
|
|
40
41
|
node.style.height = "auto";
|
|
41
42
|
node.style.height = `${node.scrollHeight}px`;
|
|
42
43
|
}, [currentValue]);
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
const node = textareaRef.current;
|
|
46
|
+
const content = contentRef.current;
|
|
47
|
+
if (!node || !content) return;
|
|
48
|
+
const observer = new ResizeObserver(() => {
|
|
49
|
+
content.style.setProperty("--ds-composer-text-height", `${node.offsetHeight}px`);
|
|
50
|
+
});
|
|
51
|
+
observer.observe(node);
|
|
52
|
+
return () => observer.disconnect();
|
|
53
|
+
}, []);
|
|
43
54
|
const submit = () => {
|
|
44
55
|
if (streaming || !canSend) return;
|
|
45
56
|
onSubmit?.(currentValue);
|
|
@@ -71,7 +82,7 @@ const Composer = React.forwardRef(
|
|
|
71
82
|
onClick: handleShellClick,
|
|
72
83
|
children: [
|
|
73
84
|
attachments && /* @__PURE__ */ jsx("div", { className: `${baseClass}__attachments`, children: attachments }),
|
|
74
|
-
/* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, children: /* @__PURE__ */ jsx(
|
|
85
|
+
/* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, ref: contentRef, children: /* @__PURE__ */ jsx(
|
|
75
86
|
"textarea",
|
|
76
87
|
{
|
|
77
88
|
...rest,
|
|
@@ -192,8 +192,17 @@
|
|
|
192
192
|
TABLES
|
|
193
193
|
============================================ */
|
|
194
194
|
|
|
195
|
+
/* The table is its own scroll container. Prose styles markup it does not
|
|
196
|
+
render, so it cannot wrap the table in a scrolling div — `display: block`
|
|
197
|
+
makes the table element itself the scroller. The cost is deliberate: the
|
|
198
|
+
table sizes to its content instead of filling the container, because the
|
|
199
|
+
inner anonymous table box a block table generates cannot be stretched.
|
|
200
|
+
A clipped or shattered table is the worse failure. */
|
|
195
201
|
.ds-prose table {
|
|
196
|
-
|
|
202
|
+
display: block;
|
|
203
|
+
width: max-content;
|
|
204
|
+
max-width: 100%;
|
|
205
|
+
overflow-x: auto;
|
|
197
206
|
border-collapse: collapse;
|
|
198
207
|
}
|
|
199
208
|
|
|
@@ -207,10 +216,18 @@
|
|
|
207
216
|
text-align: start;
|
|
208
217
|
}
|
|
209
218
|
|
|
219
|
+
/* Cells never break words, whatever the surrounding context does. An
|
|
220
|
+
ancestor that sets `overflow-wrap: anywhere` (ChatMessage does, so a long
|
|
221
|
+
URL cannot widen a bubble) otherwise inherits in and collapses every
|
|
222
|
+
column's minimum width to one character, which lets auto table layout
|
|
223
|
+
squeeze a heading into "Co / m / pa / ny" rather than let the table reach
|
|
224
|
+
its natural width. Holding the floor is what gives the scroll above
|
|
225
|
+
something to scroll. */
|
|
210
226
|
.ds-prose th,
|
|
211
227
|
.ds-prose td {
|
|
212
228
|
padding: var(--padding-xs) var(--padding-sm-md);
|
|
213
229
|
border-block-end: var(--border-xs) solid var(--color-divider);
|
|
230
|
+
overflow-wrap: normal;
|
|
214
231
|
}
|
|
215
232
|
|
|
216
233
|
/* ============================================
|
|
@@ -128,12 +128,16 @@
|
|
|
128
128
|
align-items: center;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/* The band travels dim → bright → dim, and stays narrow, matching
|
|
132
|
+
AgentStatus's sweep stop for stop: the two sit in the same conversation,
|
|
133
|
+
so a shimmer that read differently here would look like a second effect
|
|
134
|
+
rather than the same one. */
|
|
131
135
|
.ds-reasoning--streaming .ds-reasoning__summary:not(.ds-reasoning__summary--node) {
|
|
132
136
|
background-image: linear-gradient(
|
|
133
137
|
100deg,
|
|
134
|
-
var(--color-text-
|
|
135
|
-
var(--color-text-
|
|
136
|
-
var(--color-text-
|
|
138
|
+
var(--color-text-tertiary) 44%,
|
|
139
|
+
var(--color-text-primary) 50%,
|
|
140
|
+
var(--color-text-tertiary) 56%
|
|
137
141
|
);
|
|
138
142
|
background-size: 250% 100%;
|
|
139
143
|
background-clip: text;
|
|
@@ -183,7 +187,18 @@
|
|
|
183
187
|
the thought-for line never shifts the trace. */
|
|
184
188
|
margin-left: calc(var(--icon-size-md) / 2 - var(--border-md) / 2);
|
|
185
189
|
padding-left: calc(var(--icon-size-md) / 2 + var(--gap-sm-md) - var(--border-md) / 2);
|
|
186
|
-
|
|
190
|
+
/* The rail dissolves at its foot — the Timeline's end-of-line treatment.
|
|
191
|
+
A border can't carry a gradient, so the border only reserves the
|
|
192
|
+
geometry (transparent, same width) and the stripe is painted under it:
|
|
193
|
+
a border-box background sized to the border's own column. */
|
|
194
|
+
border-left: var(--border-md) solid transparent;
|
|
195
|
+
background:
|
|
196
|
+
linear-gradient(
|
|
197
|
+
to bottom,
|
|
198
|
+
var(--color-divider) calc(100% - var(--gap-lg) * 2),
|
|
199
|
+
var(--color-bg-container-primary-transparent)
|
|
200
|
+
)
|
|
201
|
+
left top / var(--border-md) 100% no-repeat border-box;
|
|
187
202
|
color: var(--color-text-secondary);
|
|
188
203
|
font-family: var(--font-paragraph-family);
|
|
189
204
|
font-size: var(--font-paragraph-size);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* ShaderField — the canvas only.
|
|
2
|
+
Placement, clipping and any fade-to-page mask belong to the positioned
|
|
3
|
+
ancestor the caller provides: where the field sits is a layout decision,
|
|
4
|
+
and baking one in would make the component unusable anywhere else. */
|
|
5
|
+
|
|
6
|
+
.ds-shader-field {
|
|
7
|
+
position: absolute;
|
|
8
|
+
inset: 0;
|
|
9
|
+
width: 100%;
|
|
10
|
+
height: 100%;
|
|
11
|
+
display: block;
|
|
12
|
+
/* Scenery, never a target — a full-bleed canvas that swallowed clicks would
|
|
13
|
+
make whatever it covers unreachable. */
|
|
14
|
+
pointer-events: none;
|
|
15
|
+
/* Reveals only once a context is live and the first frame is drawn, so a
|
|
16
|
+
machine that cannot run the shader never shows an empty canvas — it stays
|
|
17
|
+
on the caller's fallback instead. */
|
|
18
|
+
opacity: 0;
|
|
19
|
+
transition: opacity var(--motion-duration-slower) var(--motion-ease-entrance);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.ds-shader-field[data-status='active'] {
|
|
23
|
+
opacity: 1;
|
|
24
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
import { ShaderBlob, ShaderParams } from './useShaderField';
|
|
3
|
+
export type { ShaderBlob, ShaderParams, ShaderFieldParams, ShaderFieldState, } from './useShaderField';
|
|
4
|
+
export { useShaderField, DEFAULT_SHADER_BLOBS, DEFAULT_SHADER_PARAMS } from './useShaderField';
|
|
5
|
+
export { BLOB_COUNT } from './field.glsl';
|
|
6
|
+
/**
|
|
7
|
+
* How the renderer has resolved.
|
|
8
|
+
*
|
|
9
|
+
* `pending` is the one that matters to a caller: the context is still coming
|
|
10
|
+
* up, so neither the field nor a fallback should be painted yet — showing a
|
|
11
|
+
* fallback here and swapping it a frame later reads as two backgrounds loading
|
|
12
|
+
* in sequence.
|
|
13
|
+
*/
|
|
14
|
+
export type ShaderFieldStatus = 'pending' | 'active' | 'unavailable';
|
|
15
|
+
/** Props owned by ShaderField itself — everything else falls through to the canvas. */
|
|
16
|
+
type ShaderFieldOwnProps = {
|
|
17
|
+
/**
|
|
18
|
+
* Field parameters, merged over the shipped defaults, so passing one
|
|
19
|
+
* property changes one thing. Changing them never rebuilds the GL state.
|
|
20
|
+
*/
|
|
21
|
+
params?: Partial<ShaderParams>;
|
|
22
|
+
/**
|
|
23
|
+
* The light sources. Defaults to an eight-source composition on the core
|
|
24
|
+
* accent tokens. Fewer than `BLOB_COUNT` entries is fine — unused slots park
|
|
25
|
+
* off-field; more are ignored, because the shader's uniform arrays are
|
|
26
|
+
* fixed-size.
|
|
27
|
+
*/
|
|
28
|
+
blobs?: readonly ShaderBlob[];
|
|
29
|
+
/**
|
|
30
|
+
* False keeps WebGL entirely unmounted and reports `unavailable`, so the
|
|
31
|
+
* caller's fallback is the whole background. This is the kill switch: a
|
|
32
|
+
* config lever, or an A/B against the fallback.
|
|
33
|
+
*/
|
|
34
|
+
enabled?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Called whenever the renderer's status changes. Drive the fallback layer's
|
|
37
|
+
* visibility from this — the component deliberately does not own one, since
|
|
38
|
+
* what to paint instead is a design decision, not a rendering one.
|
|
39
|
+
*/
|
|
40
|
+
onStatusChange?: (status: ShaderFieldStatus) => void;
|
|
41
|
+
/** Additional CSS classes */
|
|
42
|
+
className?: string;
|
|
43
|
+
};
|
|
44
|
+
export interface ShaderFieldProps extends ShaderFieldOwnProps, Omit<React.ComponentPropsWithoutRef<'canvas'>, keyof ShaderFieldOwnProps> {
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* ShaderField paints an ambient WebGL2 field of soft Gaussian light sources,
|
|
48
|
+
* each sampling a semantic colour token at runtime — so the whole thing
|
|
49
|
+
* re-themes with `data-theme`, and with any scoped custom-property override,
|
|
50
|
+
* without the caller wiring anything up.
|
|
51
|
+
*
|
|
52
|
+
* It is scenery, not interface: nothing about it is a signal, so its motion
|
|
53
|
+
* cannot be mistaken for feedback. Two consequences follow, and both are
|
|
54
|
+
* handled here rather than left to the caller.
|
|
55
|
+
*
|
|
56
|
+
* It honours reduced motion in JavaScript. The system's motion tokens collapse
|
|
57
|
+
* under `prefers-reduced-motion`, but that guard is CSS-only and cannot see a
|
|
58
|
+
* `requestAnimationFrame` loop; a GPU-driven surface has to check the query
|
|
59
|
+
* itself. This one renders a single static frame and never starts its loop.
|
|
60
|
+
*
|
|
61
|
+
* And it always reports how it resolved. No WebGL2, a blocked or lost GPU
|
|
62
|
+
* context, a renderer that stalls before its first frame, or `enabled={false}`
|
|
63
|
+
* all arrive at `unavailable` through `onStatusChange`, so the caller can keep
|
|
64
|
+
* a plain CSS fallback painted underneath. The canvas fills its positioned
|
|
65
|
+
* ancestor and is inert to pointer events; give that ancestor the placement,
|
|
66
|
+
* the clipping and any mask.
|
|
67
|
+
*/
|
|
68
|
+
export declare const ShaderField: React.ForwardRefExoticComponent<ShaderFieldProps & React.RefAttributes<HTMLCanvasElement>>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
import React, { useRef, useMemo, useState, useEffect } from "react";
|
|
3
|
+
import { DEFAULT_SHADER_BLOBS, DEFAULT_SHADER_PARAMS, useShaderField } from "./useShaderField.js";
|
|
4
|
+
import "./ShaderField.css";
|
|
5
|
+
const WATCHDOG_MS = 1500;
|
|
6
|
+
const ShaderField = React.forwardRef(
|
|
7
|
+
({
|
|
8
|
+
params,
|
|
9
|
+
blobs = DEFAULT_SHADER_BLOBS,
|
|
10
|
+
enabled = true,
|
|
11
|
+
onStatusChange,
|
|
12
|
+
className = "",
|
|
13
|
+
...rest
|
|
14
|
+
}, ref) => {
|
|
15
|
+
const canvasRef = useRef(null);
|
|
16
|
+
React.useImperativeHandle(ref, () => canvasRef.current);
|
|
17
|
+
const fieldParams = useMemo(
|
|
18
|
+
() => ({ ...DEFAULT_SHADER_PARAMS, ...params, blobs }),
|
|
19
|
+
[params, blobs]
|
|
20
|
+
);
|
|
21
|
+
const { supported, active } = useShaderField(canvasRef, fieldParams, enabled);
|
|
22
|
+
const [gaveUp, setGaveUp] = useState(false);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!enabled || active || !supported) return;
|
|
25
|
+
const timer = window.setTimeout(() => setGaveUp(true), WATCHDOG_MS);
|
|
26
|
+
return () => window.clearTimeout(timer);
|
|
27
|
+
}, [enabled, active, supported]);
|
|
28
|
+
const status = !enabled || !supported ? "unavailable" : active ? "active" : gaveUp ? "unavailable" : "pending";
|
|
29
|
+
const notify = useRef(onStatusChange);
|
|
30
|
+
notify.current = onStatusChange;
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
notify.current?.(status);
|
|
33
|
+
}, [status]);
|
|
34
|
+
if (!enabled) return null;
|
|
35
|
+
const classes = ["ds-shader-field", className].filter(Boolean).join(" ");
|
|
36
|
+
return /* @__PURE__ */ jsx(
|
|
37
|
+
"canvas",
|
|
38
|
+
{
|
|
39
|
+
...rest,
|
|
40
|
+
ref: canvasRef,
|
|
41
|
+
className: classes,
|
|
42
|
+
"data-status": status,
|
|
43
|
+
"aria-hidden": "true"
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
ShaderField.displayName = "ShaderField";
|
|
49
|
+
export {
|
|
50
|
+
DEFAULT_SHADER_BLOBS,
|
|
51
|
+
DEFAULT_SHADER_PARAMS,
|
|
52
|
+
ShaderField,
|
|
53
|
+
useShaderField
|
|
54
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GLSL sources for ShaderField, the system's ambient background field.
|
|
3
|
+
*
|
|
4
|
+
* This file owns the shader and BLOB_COUNT only. The blob table and every
|
|
5
|
+
* tuneable parameter are data, passed in by the caller, so a look can be
|
|
6
|
+
* retuned without touching GLSL.
|
|
7
|
+
*
|
|
8
|
+
* The field is a sum of soft Gaussian sources. It computes the true Gaussian
|
|
9
|
+
* falloff a stack of blurred CSS discs only approximates, blends in linear
|
|
10
|
+
* space so midpoints keep their saturation, warps the field with fbm noise so
|
|
11
|
+
* it flows instead of translating rigidly, and dithers away the 8-bit banding
|
|
12
|
+
* these wide, soft ramps otherwise produce.
|
|
13
|
+
*
|
|
14
|
+
* Coordinate space is chosen to match a CSS blob layer exactly, so a fallback
|
|
15
|
+
* built from absolutely-positioned discs lines up with it: origin at the
|
|
16
|
+
* container centre (`left`/`top: 50%`), one unit = container width (discs are
|
|
17
|
+
* vw-sized, and CSS percentage margins — top included — resolve against the
|
|
18
|
+
* containing block's width). Centres are therefore constants that never need
|
|
19
|
+
* re-uploading on resize.
|
|
20
|
+
*
|
|
21
|
+
* Colours arrive already converted to linear RGB (the hook does the sRGB
|
|
22
|
+
* decode once per theme change); the shader only encodes back at the end.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Sizes the shader's uniform arrays, so it is a compile-time constant here
|
|
26
|
+
* rather than a length read from data. A caller may pass fewer blobs — unused
|
|
27
|
+
* slots are parked off-field — but never more; ShaderField's own default table
|
|
28
|
+
* fills it exactly, and scripts/validate-shader-background.mjs reads this
|
|
29
|
+
* number out of this file to hold the website's config to it.
|
|
30
|
+
*/
|
|
31
|
+
export declare const BLOB_COUNT = 8;
|
|
32
|
+
export declare const vertexSource = "#version 300 es\n// Fullscreen triangle \u2014 no buffers, three vertices from gl_VertexID.\nvoid main() {\n vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}\n";
|
|
33
|
+
export declare const fragmentSource = "#version 300 es\nprecision highp float;\n\nuniform vec2 u_resolution; // drawing-buffer size in pixels\nuniform float u_time; // field time, seconds (speed applied JS-side)\nuniform vec3 u_color[8]; // linear RGB per blob\nuniform vec4 u_blob[8]; // xy = centre, z = sigma, w = emission weight\nuniform vec4 u_motion[8]; // xy = angular speeds, zw = phases\nuniform float u_intensity; // peak-alpha ceiling\nuniform float u_warp; // domain-warp strength\nuniform float u_scale; // noise frequency\nuniform float u_grain; // film-grain strength\nuniform float u_streak; // anisotropic stretch: 0 = blobs, 1 = light streams\nuniform vec2 u_mouse; // pointer in field coordinates (smoothed JS-side)\nuniform float u_mvel; // smoothed pointer speed, width units/s\nuniform float u_react; // cursor-reactivity strength\n\nout vec4 outColor;\n\n// Integer hash \u2014 WebGL2 has real uint ops, so no fract(sin()) precision\n// lottery on mobile GPUs.\nuint ihash(uvec2 p) {\n p = p * uvec2(73333u, 7777u) ^ (p.yx >> 3u);\n uint h = p.x ^ (p.y * 2654435761u);\n h ^= h >> 15u;\n h *= 2246822519u;\n h ^= h >> 13u;\n return h;\n}\n\nfloat rnd(ivec2 p) {\n return float(ihash(uvec2(p)) >> 8) * (1.0 / 16777216.0);\n}\n\nfloat vnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);\n ivec2 c = ivec2(i);\n return mix(\n mix(rnd(c), rnd(c + ivec2(1, 0)), u.x),\n mix(rnd(c + ivec2(0, 1)), rnd(c + ivec2(1, 1)), u.x),\n u.y\n );\n}\n\n// 3 octaves; a 4th is invisible at this softness.\nfloat fbm(vec2 p) {\n float s = 0.0;\n float a = 0.5;\n for (int i = 0; i < 3; i++) {\n s += a * vnoise(p);\n p = p * 2.02 + vec2(17.0, 9.0);\n a *= 0.5;\n }\n return s;\n}\n\nvoid main() {\n // CSS coordinate space: origin at container centre, one unit = width,\n // y running downward.\n vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.x;\n p.y = -p.y;\n\n // Light-stream axis: a fixed diagonal. Distances across the axis are\n // magnified before the Gaussian, so every source stretches along it and\n // the field reads as rays instead of discs.\n float ca = cos(0.5);\n float sa = sin(0.5);\n mat2 toStreak = mat2(ca, -sa, sa, ca);\n float squeeze = 1.0 + u_streak * 4.0;\n\n // Domain warp: the field flows rather than drifts. The noise is sampled\n // in the same anisotropic space, so the flow lines follow the streams.\n float t = u_time;\n vec2 ps = toStreak * p;\n ps.y *= squeeze;\n vec2 q = vec2(\n fbm(ps * u_scale + vec2(0.0, t * 0.030)),\n fbm(ps * u_scale + vec2(5.2, 1.3) - t * 0.024)\n );\n vec2 w = p + u_warp * (q - 0.5);\n\n // Cursor wake: a smooth vortex around the pointer, amplified by speed.\n // The displacement is built from the *unnormalised* offset so it falls to\n // zero at the cursor itself. Normalising here (md/mr) made the direction\n // spin through 360 degrees at full strength across the centre pixel, which\n // rendered as a pinch \u2014 a cone converging to a point.\n vec2 md = p - u_mouse;\n float mr2 = dot(md, md);\n float minf = exp(-mr2 / (2.0 * 0.22 * 0.22));\n vec2 swirl = vec2(-md.y, md.x);\n // smoothstep, not min(): a hard clamp stepped visibly as speed crossed it.\n float stir = 0.25 + 1.3 * smoothstep(0.0, 0.8, u_mvel);\n w += u_react * minf * stir * (swirl * 0.9 + md * 0.35);\n\n // Accumulate true Gaussians in linear space. Positive weights emit;\n // negative weights absorb (shadows \u2014 the eclipse disc).\n vec3 acc = vec3(0.0);\n float cov = 0.0;\n float shade = 0.0;\n for (int i = 0; i < 8; i++) {\n vec2 c = u_blob[i].xy + 0.045 * vec2(\n cos(u_motion[i].x * t + u_motion[i].z),\n sin(u_motion[i].y * t + u_motion[i].w)\n );\n vec2 d = toStreak * (w - c);\n d.y *= squeeze;\n float s = u_blob[i].z;\n float g = exp(-dot(d, d) / (2.0 * s * s));\n float wgt = u_blob[i].w;\n if (wgt >= 0.0) {\n acc += u_color[i] * (g * wgt);\n cov += g * wgt;\n } else {\n shade += g * -wgt;\n }\n }\n\n // Glow trail: wider and softer than the swirl, and mostly velocity-driven,\n // so a resting cursor does not park a bright dot on the field.\n //\n // The wake brightens whatever colour it is passing over rather than adding\n // one of its own. Injecting u_color[0] meant the trail always wore the\n // first blob's token \u2014 gold in the site palette, violet in beam \u2014 which\n // read as a stray colour with no relationship to the field beneath it.\n // Weighting by the local hue keeps the wake colour-neutral: it reads as\n // light falling on the field, and it stays correct for any palette.\n vec3 local = acc / max(cov, 1e-4);\n float gfall = exp(-mr2 / (2.0 * 0.3 * 0.3));\n float glow = u_react * gfall * (0.12 + 0.5 * smoothstep(0.0, 0.8, u_mvel));\n acc += local * glow;\n cov += glow;\n\n vec3 lin = acc / max(cov, 1e-4);\n float lit = max(cov - shade, 0.0);\n float alpha = u_intensity * (1.0 - exp(-lit * 0.9));\n\n vec3 srgb = pow(max(lin, vec3(0.0)), vec3(1.0 / 2.2));\n\n // Film grain: animated hash noise. At u_grain 0 this degenerates to the\n // \u00B11/255 dither that kills banding, so the two share one lookup.\n uvec2 gp = uvec2(gl_FragCoord.xy) + uvec2(\n uint(fract(t * 7.31) * 1024.0) * 7919u,\n uint(fract(t * 3.17) * 1024.0) * 104729u\n );\n float gr = float(ihash(gp) & 255u) / 255.0 - 0.5;\n srgb += gr * (1.0 / 255.0 + u_grain * 0.35);\n // A small grain-driven alpha floor lets the sparkle read even where the\n // field itself is empty (the reference's grainy dark scene).\n float grainFloor = u_grain * 0.1 * (gr + 0.5);\n alpha = clamp(alpha * (1.0 + gr * u_grain * 0.5) + grainFloor, 0.0, 1.0);\n srgb = clamp(srgb, 0.0, 1.0);\n\n // Premultiplied output (context is created with premultipliedAlpha: true).\n outColor = vec4(srgb * alpha, alpha);\n}\n";
|