diff --git a/.changeset/build-scope-source-condition.md b/.changeset/build-scope-source-condition.md index 1543adb6b18d..171a18cffb13 100644 --- a/.changeset/build-scope-source-condition.md +++ b/.changeset/build-scope-source-condition.md @@ -4,7 +4,7 @@ [fix] Scope the `source` resolve condition to @astryxdesign packages in withAstryx -`withAstryx` set webpack's `conditionNames` to `['source', …]` globally, which resolved *any* dependency shipping a `source` export to its raw TypeScript — not just Astryx packages. Third-party deps that ship a `source` export (e.g. `lexical`, pulled in by the new RichTextEditor lab component) were then fed untranspiled `.ts` through Next's babel and failed on syntax like `declare` class fields. +`withAstryx` set webpack's `conditionNames` to `['source', …]` globally, which resolved _any_ dependency shipping a `source` export to its raw TypeScript — not just Astryx packages. Third-party deps that ship a `source` export (e.g. `lexical`, pulled in by the new RichTextEditor lab component) were then fed untranspiled `.ts` through Next's babel and failed on syntax like `declare` class fields. The `source` condition is now applied via a scoped **allowlist** `module.rules` entry that only matches `node_modules/@astryxdesign/*` (with `conditionNames: ['source', '...']`, so it augments rather than replaces Next's defaults). The global `config.resolve.conditionNames` is left as Next's default, so all other packages — including any future third-party dep that happens to ship a `source` export — resolve to their built output. React JSX resolution is preserved via the inherited `'...'` defaults + `react-server` condition. Astryx source builds are unaffected. diff --git a/.changeset/table-row-status.md b/.changeset/table-row-status.md new file mode 100644 index 000000000000..033cbacd6ddf --- /dev/null +++ b/.changeset/table-row-status.md @@ -0,0 +1,20 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Add `useTableRowStatus`, a plugin that prepends a narrow column +signaling per-row status. + +- Compact per-row status signal (error, warning, unread, etc.) without a + dedicated status column: a colored status dot by default, or an icon when + provided. +- `getStatus(item)` maps a row to `{color, icon?, label?}`, or `null` for no + indicator. `color` accepts a semantic status name + (`success`/`error`/`warning`/`accent`/`red`/`green`/etc.) mapped to a theme + token, or a raw CSS color as an escape hatch. +- `icon` renders the status as a shape signifier instead of the dot, which is + more accessible than color alone when multiple statuses coexist. `label` + supplies the accessible name. +- Memoize `getStatus` with `useCallback` for a stable plugin identity. + +@humbertovirtudes diff --git a/.github/instructions/packages.instructions.md b/.github/instructions/packages.instructions.md index 56391c9fbdab..7efa79220925 100644 --- a/.github/instructions/packages.instructions.md +++ b/.github/instructions/packages.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "packages/**" +applyTo: 'packages/**' --- # Package review instructions @@ -9,25 +9,25 @@ against Astryx's API guidance and component review protocol. ## Step 0 — Triage first: categorize, assess risk, pick a path -Before reviewing in depth, do a fast triage. It decides *how hard* to look and +Before reviewing in depth, do a fast triage. It decides _how hard_ to look and in what order, so effort lands where the risk is — and so the risk checks (especially breaking changes) happen **up front**, not as an afterthought. **1. Categorize the PR** (by what it changes, not just the title prefix): -| Category | Signals | -|---|---| +| Category | Signals | +| ----------------------- | ----------------------------------------------------------------------------------------- | | **test / docs / chore** | only `*.test.tsx`, `*.doc.mjs`, stories, CI, build config — no shipped runtime/API change | -| **bug fix** | behavior change to existing code, no new public surface | -| **new API surface** | new component, new prop/variant, new exported hook/type, changed signature | -| **refactor / internal** | behavior-preserving restructure, shared-util migration | +| **bug fix** | behavior change to existing code, no new public surface | +| **new API surface** | new component, new prop/variant, new exported hook/type, changed signature | +| **refactor / internal** | behavior-preserving restructure, shared-util migration | **2. Assess risk — the two questions that gate everything:** - **Is it a breaking change?** Scan for: a removed/renamed public export, prop, or variant; a **new required** field on a public type/context/props; a changed default; a changed function signature; or changed DOM/class/ARIA output - consumers may depend on. (The tell for an *accidental* breaking change: + consumers may depend on. (The tell for an _accidental_ breaking change: unrelated tests/examples/call sites had to be edited — see the silent-breaking rule in Judgment.) A real breaking change must be **intentional and signalled with a `[breaking]` changeset category** (pre-1.0 stays a `patch` bump — never @@ -63,8 +63,8 @@ in what order, so effort lands where the risk is — and so the risk checks > reviewer must **not** > approve or merge it — even if it's clean, additive, and passing CI. Flag it as > **⚠️ Needs human/maintainer judgment on the API surface** and leave the -> approve/merge decision to a human. "Additive and non-breaking" is *not* -> sufficient to auto-approve API surface — whether the API *should exist* and +> approve/merge decision to a human. "Additive and non-breaking" is _not_ +> sufficient to auto-approve API surface — whether the API _should exist_ and > take this shape is a human call. Behavior-only fixes, tests, docs, and chores > from contributors can still take the fast/standard path. @@ -77,7 +77,7 @@ breaking-change question is answered on every PR. The shared **Review buckets** and **Review Signal** model lives in the root `copilot-instructions.md` — apply it. This section defines what counts as -**high-risk** *within `packages/**`*, which is what drives the signal: +**high-risk** _within `packages/**`_, which is what drives the signal: A change in this scope is **high-risk** when it involves any of: @@ -92,7 +92,7 @@ A change in this scope is **high-risk** when it involves any of: breaking change to a shared type/context (see Judgment). Anything else in this scope is **low-risk** for signal purposes. The one -explicitly low-risk *area* inside `packages/**` is **`packages/themes/**`** +explicitly low-risk _area_ inside `packages/**` is **`packages/themes/**`** (theme values); a theme-only change does not trip the high-risk gate — though still apply the design blast-radius check in Judgment (a token change can regress everywhere it composites). @@ -101,13 +101,13 @@ The high-risk determination is also computed deterministically by `.github/workflows/review-signal.yml`, which applies the **`needs:code-review`** label and disables auto-merge on high-risk PRs. If the PR carries that label, lead with 🔴 and focus on the high-risk surface (see the label note in the root -instructions). Frame the review by bucket — the bucket sets *tone*, the area -sets the *gate*: +instructions). Frame the review by bucket — the bucket sets _tone_, the area +sets the _gate_: -- **Contributor** → your review is the *initial pass* that tells a code owner +- **Contributor** → your review is the _initial pass_ that tells a code owner where to focus. For a new prop / API change / new component / new package, add the explicit **⚠️ Needs human/maintainer judgment on the API surface** note — - additive and passing CI is *not* sufficient to imply approval. + additive and passing CI is _not_ sufficient to imply approval. - **Design owner** → same checks, framed for a designer: name what crosses into engineering territory and needs an engineer's eye. - **Eng team** → assistant framing: surface the same findings as input to the @@ -124,11 +124,11 @@ Once triaged, weight the review by what the PR is trying to do: passing evidence and ask for it — a fix without a regression test can silently break again. - **Tests** — a test PR must earn its merge by testing the **contract, not the - implementation**. A passing test that exists is necessary but *not* sufficient + implementation**. A passing test that exists is necessary but _not_ sufficient to approve; judge it against the bar below and, if it's implementation-coupled or padding, request changes (kindly) naming the specific assertions to cut or - refocus. The one-line test: *does this protect a promise a consumer relies on, - or does it just mirror the code?* If it would break on a harmless refactor yet + refocus. The one-line test: _does this protect a promise a consumer relies on, + or does it just mirror the code?_ If it would break on a harmless refactor yet survive a real bug, it's slop. - **✅ Worth testing** — the public behavioral contract (state transitions, controlled/uncontrolled, callbacks fire with the right args, documented edge @@ -141,14 +141,14 @@ Once triaged, weight the review by what the PR is trying to do: (change-detector tests); snapshot dumps with no behavioral assertion; re-testing React or the library ("useState updates", "prop passed through" with no logic between); trivial padding ("renders without crashing" as the - *only* assertion, or `it.each` explosions with no distinct risk per case); + _only_ assertion, or `it.each` explosions with no distinct risk per case); and computed style/token/pixel assertions (that's brittle design territory). - **Docs** — validate against reality. Check that the documentation is actually correct (matches the code/API/behavior on this branch). When a claim is a matter of best practice or judgment rather than fact, **call it out for a maintainer** rather than asserting it's right or wrong. -- **New features / new components** — whether this is the *right way to expose - the functionality* is a human judgment call. **Do not render a verdict on the +- **New features / new components** — whether this is the _right way to expose + the functionality_ is a human judgment call. **Do not render a verdict on the API design here.** You may still run the mechanical, convention, and convergence checks below, but explicitly **flag that maintainers should review the API surface carefully** (and that new surface should be spec'd and @@ -167,7 +167,7 @@ Add an explicit **"⚠️ Needs engineering / human judgment"** note when the ch involves any of: - **New public API surface or an API-shape decision** — a new component, a new - prop/variant, or changing an existing prop's contract. (The *right* shape is a + prop/variant, or changing an existing prop's contract. (The _right_ shape is a human call — see New features above.) - **New runtime complexity** — effects, refs, observers (`MutationObserver`/`ResizeObserver`/`IntersectionObserver`), imperative DOM @@ -182,15 +182,15 @@ involves any of: - **Escape hatches / breaking the system** — raw CSS, non-token values, `swizzle`d source, or overriding a system default; these need a documented rationale an engineer signs off on. -- **Anything the change *asserts* works but can't be verified from the diff** — +- **Anything the change _asserts_ works but can't be verified from the diff** — performance claims, cross-browser/RTL/theme behavior, SSR/hydration. Pure presentational work well within the system — composing existing components, using tokens and documented props, adding a story or realistic mock data — does **not** need this flag. Reserve it for the cases above so it stays meaningful. -When you raise it, be specific: name *what* in the diff needs the deeper look and -*why* (e.g. "the `ResizeObserver` in `X.tsx` is a runtime-complexity + perf +When you raise it, be specific: name _what_ in the diff needs the deeper look and +_why_ (e.g. "the `ResizeObserver` in `X.tsx` is a runtime-complexity + perf decision — an engineer should confirm a container query wouldn't do"), so the designer knows exactly what to hand off. @@ -228,7 +228,7 @@ don't evaluate it in isolation. First check whether other components already express the same capability, and push to converge on the existing shape: - **Search for prior art.** Look for existing components with a prop of similar - *purpose* or *behavior* — the same axis of variation, even under a different + _purpose_ or _behavior_ — the same axis of variation, even under a different name (e.g. `size` vs `scale`, `isLoading` vs `busy`, `tone` vs `variant` vs `color`, `density` vs `compact`). Comparable components should already be siblings in the same family; check those first, then the wider system. @@ -250,12 +250,12 @@ express the same capability, and push to converge on the existing shape: ### Plugins & hooks that extend a host component Some components expose a plugin/hook surface (e.g. `Table` with -`useTable*` plugins). These extend a host, so review them for consistency *with -that host*, not in isolation — recurring issues seen in real review: +`useTable*` plugins). These extend a host, so review them for consistency _with +that host_, not in isolation — recurring issues seen in real review: - **Mirror the host's API shape, in and out.** A plugin should accept and return the same shapes the host already uses. If `Table` accepts `idKey` (a key that - may be a string *or* a getter, so callers avoid writing callbacks), a plugin + may be a string _or_ a getter, so callers avoid writing callbacks), a plugin should accept the same rather than forcing a bespoke callback — and should **name its outputs to match the host's props** so they compose directly. Prefer `const {idKey} = usePlugin(); ` over exporting @@ -264,7 +264,7 @@ that host*, not in isolation — recurring issues seen in real review: - **Semantic values first, arbitrary as the escape hatch.** When a plugin/prop takes a visual value (color, status, tone), the first-class API is the system's **semantic tokens** (`color: 'accent'` / `'success'`), not raw values. - Allowing arbitrary values is fine as an escape hatch, but the *default* shape + Allowing arbitrary values is fine as an escape hatch, but the _default_ shape should be system semantics — flag an API where the raw/arbitrary form is the primary one. - **Decide host-level vs plugin-level deliberately** — especially for @@ -292,7 +292,7 @@ For hooks (plugins or otherwise), watch two things that bit real Table PRs: ## Design review -Some package changes are also *design* changes. When a diff affects how a +Some package changes are also _design_ changes. When a diff affects how a component **looks or behaves visually**, review it against **[Design Conventions](https://github.com/facebook/astryx/wiki/Design-Conventions)** — the design-side sibling of API Conventions — in addition to the checks below. @@ -327,7 +327,7 @@ flag the concrete "smells" that page names: arbitrary z-index and hairline-border-plus-diffuse-shadow or colored glows. - **Typography** — role tokens; hierarchy ≥1.25 size ratio; body ≥12px; leading ≥1.3; flag flat hierarchy, all-caps/justified/gradient body, lines >~75ch. -- **Color** — every fg/bg pair passes WCAG AA in light *and* dark; interaction +- **Color** — every fg/bg pair passes WCAG AA in light _and_ dark; interaction tints are alpha overlays (not opaque); status pairs color with an icon (never color alone); one clear primary action; no pure `#000`/`#fff`. - **Motion** — duration matches the change's weight; only `transform`/`opacity` @@ -361,13 +361,13 @@ high-attention (post a note rather than hard-blocking — this is advisory). **Flag a diff that adds a brand-new component directory under `packages/core/src//` with no prior presence in `packages/lab/src/`.** -That's a component skipping the staging step. (A lab→core *promotion* — a delete +That's a component skipping the staging step. (A lab→core _promotion_ — a delete under `packages/lab/src/**` paired with the add in core — is the healthy path -and is fine; the concern is the *net-new* component that was never in lab.) +and is fine; the concern is the _net-new_ component that was never in lab.) When you see a net-new core component, ask the author to confirm either that it went through lab, or that it genuinely meets the core bar that lab explicitly -does *not* guarantee: +does _not_ guarantee: - Full keyboard + a11y (ARIA contracts, focus, `:hover` guarded by `@media (hover: hover)`) @@ -408,7 +408,7 @@ regardless of author, and confirm: - **A tracking/spec issue is linked** establishing the need and scope. Never approve a net-new package on convention-cleanliness alone; whether it -*should exist* is a human call. +_should exist_ is a human call. ### New template added already-visible (not `hidden`) @@ -417,7 +417,7 @@ the template design bar. The CLI reads `hidden: true` and `hiddenComponents: ['Name', ...]` from a template's `.doc.mjs`; hidden entries are skipped from `--list`. -**Flag a diff that adds a *new* template/block whose `.doc.mjs` is not +**Flag a diff that adds a _new_ template/block whose `.doc.mjs` is not `hidden: true`** (i.e. it's publicly listed from the moment it lands). A new template appearing already-visible skipped the hidden-staging step and may not be hardened yet. Ask the author to confirm it meets the template design bar @@ -445,10 +445,10 @@ and the Design review section above), or to add `hidden: true` until it does. - **Avoid unnecessary wrapper elements — prefer props and hooks.** Astryx favors attaching behavior/style to existing elements over adding a new wrapper node. Flag an added wrapper when a lighter path exists: - - *Styling:* components extend `BaseProps` (they take `xstyle`), so apply style + - _Styling:_ components extend `BaseProps` (they take `xstyle`), so apply style directly — `` — instead of wrapping the component in a styled `
`. - - *Behavior:* reach for the behavior **hook** or **prop** the system already + - _Behavior:_ reach for the behavior **hook** or **prop** the system already exposes rather than a wrapper component. E.g. a tooltip is available via the `tooltip` prop / `useTooltip` hook, and there are hooks for many behaviors (`useHoverCard`, `useClickableContainer`, `useCollapsible`, `useFocusTrap`, @@ -470,11 +470,11 @@ and the Design review section above), or to add `hidden: true` until it does. or **`useTreeFocus`**; typeahead → **`useTypeahead`**; a keyboard-shortcut hint → **`useKeyboardHint`**; focus trapping → **`useFocusTrap`**; interactive role/state wiring → **`useInteractiveRole`**. - These already implement the WAI-ARIA APG patterns and are tested, so reusing - them keeps behavior consistent across the system. A genuinely new a11y pattern - with no existing primitive is fine — but call it out for careful review (see - "When to flag for engineering / human judgment") rather than landing a bespoke - implementation quietly. + These already implement the WAI-ARIA APG patterns and are tested, so reusing + them keeps behavior consistent across the system. A genuinely new a11y pattern + with no existing primitive is fine — but call it out for careful review (see + "When to flag for engineering / human judgment") rather than landing a bespoke + implementation quietly. - **Docs in sync** — JSDoc file headers, `SYNC:` reminders, and `.doc.mjs`. `@example` fences in JSDoc must be plain ` ``` ` (never language-tagged), or Storybook autodocs won't render them. @@ -499,8 +499,8 @@ checks, flag it. especially should be rare and deliberate. - **Worsen the experience it's trying to help** — e.g. announcing on every keystroke, or moving focus in a way that traps or disorients. - Prefer announcing on a real state transition, debouncing/coalescing where the - content is noisy, and reserving `assertive` for genuinely urgent messages. + Prefer announcing on a real state transition, debouncing/coalescing where the + content is noisy, and reserving `assertive` for genuinely urgent messages. - **`useEffect` is a smell.** Treat a new/changed Effect as something to justify, not accept by default. Most UI logic doesn't need one — look for whether it belongs in an **event handler / callback** (logic that responds to a user @@ -509,7 +509,7 @@ checks, flag it. state). Use React's own guidance as the bar: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) and [Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects). - Genuine Effects synchronize with an *external* system (subscriptions, the DOM, + Genuine Effects synchronize with an _external_ system (subscriptions, the DOM, network, non-React widgets) — those are fine; call out the ones that don't. - **Overly complex behavior for a simple need.** Flag heavy runtime machinery added where a simpler, declarative solution exists — the classic being a @@ -532,18 +532,18 @@ checks, flag it. instead — and whether the breaking change is worth it. (Real case: a new required `aria-controls` id on a mobile-nav context forced edits to surfaces that didn't otherwise need it.) -- **Unintended behavior/logic change.** Compare what the diff *actually changes* +- **Unintended behavior/logic change.** Compare what the diff _actually changes_ against what the PR says it does. Flag behavior the author likely didn't mean to touch — a value, default, condition, or output that changed as a side effect of the intended edit and that the description never mentions. This is different - from scope contamination (unrelated *files* bundled in); here the collateral - change is *inside* the area the author was working on, so it's easy to miss. + from scope contamination (unrelated _files_ bundled in); here the collateral + change is _inside_ the area the author was working on, so it's easy to miss. Watch especially for: - **Design/token changes — judge by presentation impact, not the token type.** When a change touches a token, theme value, or style, reason about **how it renders and where that could break**, not just that a value changed. Ask: does this alter contrast, legibility, or emphasis? Does it change how a value - *composites* over other surfaces or in a different theme/mode (a change that + _composites_ over other surfaces or in a different theme/mode (a change that looks fine on the one screen the author checked but regresses elsewhere)? Does it shift layout, spacing rhythm, or elevation order? Does it hold in both light and dark, and against every surface the token appears on? The @@ -557,11 +557,11 @@ checks, flag it. (`>=` vs `>`), an early-return, or a guard that changed as a byproduct. - **Generated-output drift** — a regenerated theme CSS / registry / token file whose diff contains changes beyond the intended one. - When you spot this, don't assume malice or intent — surface it as a question: - "this also changes X (was `a`, now `b`) — does that hold everywhere it's used?" - Naming it lets the author confirm or revert. The author being unaware, and - having checked only the surface they were working on, is exactly why the - reviewer checks the blast radius of a design change. + When you spot this, don't assume malice or intent — surface it as a question: + "this also changes X (was `a`, now `b`) — does that hold everywhere it's used?" + Naming it lets the author confirm or revert. The author being unaware, and + having checked only the surface they were working on, is exactly why the + reviewer checks the blast radius of a design change. - **Other smells.** State expressed by unmounting focusable elements (toggle visibility so focus/a11y survive), unnecessary `useState` (prefer derived values or refs, especially from interaction handlers), and excessive comments. diff --git a/apps/sandbox/README.md b/apps/sandbox/README.md index 03d496f7a34e..0a1ae6f20fba 100644 --- a/apps/sandbox/README.md +++ b/apps/sandbox/README.md @@ -89,12 +89,12 @@ Edits trigger incremental dist rebuilds via Babel CLI (a few seconds), and CSS l | File | Purpose | | -------------------------- | ------------------------------------------------------------------------ | -| `package.json` | Dependencies; uses PostCSS path for StyleX | +| `package.json` | Dependencies; uses PostCSS path for StyleX | | `babel.config.js` | StyleX babel plugin config (as plugin, not preset) | -| `postcss.config.js` | StyleX PostCSS plugin: extracts CSS from `@stylex;` directive | +| `postcss.config.js` | StyleX PostCSS plugin: extracts CSS from `@stylex;` directive | | `next.config.mjs` | Static export, basePath for GitHub Pages, webpack alias for theme tokens | | `tsconfig.json` | TypeScript config with workspace path aliases | -| `src/app/globals.css` | `@stylex;` injection point: PostCSS replaces this with extracted CSS | +| `src/app/globals.css` | `@stylex;` injection point: PostCSS replaces this with extracted CSS | | `src/app/providers.tsx` | Client-side theme provider wrapper | | `src/app/layout.tsx` | Root layout with sidebar navigation | | `src/app/Sidebar.tsx` | Sidebar navigation component | diff --git a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx index da943088c3d4..141f0a593166 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx @@ -49,6 +49,7 @@ import { useTableRowExpansionState, useTableGroupedRows, useTableRowIndex, + useTableRowStatus, } from '@astryxdesign/core/Table'; import type {TablePlugin, TableSortState} from '@astryxdesign/core/Table'; @@ -72,7 +73,8 @@ type PluginId = | 'stickyColumns' | 'rowExpansion' | 'groupedRows' - | 'rowIndex'; + | 'rowIndex' + | 'rowStatus'; interface PluginMeta { id: PluginId; @@ -117,6 +119,11 @@ const PLUGIN_REGISTRY: PluginMeta[] = [ label: 'Row Index', description: 'Prepend a monospaced row-number column', }, + { + id: 'rowStatus', + label: 'Row Status', + description: 'Status dot / icon (by member status)', + }, ]; // ============================================================================= @@ -244,6 +251,21 @@ function useLabPlugins({ getRowKey: item => item.id, }); + // --- row status --- + const rowStatusPlugin = useTableRowStatus({ + getStatus: item => { + // Semantic color names map to theme tokens; an icon adds a shape + // differentiator so status isn't conveyed by color alone. + if (item.status === 'Active') { + return {color: 'success', icon: 'success', label: 'Active'}; + } + if (item.status === 'Away') { + return {color: 'warning', icon: 'warning', label: 'Away'}; + } + return null; // Offline: no indicator + }, + }); + // Assemble enabled plugins in a stable order. const plugins: Record> = {}; if (enabled.groupedRows) { @@ -252,6 +274,9 @@ function useLabPlugins({ if (enabled.rowIndex) { plugins.rowIndex = rowIndexPlugin; } + if (enabled.rowStatus) { + plugins.rowStatus = rowStatusPlugin; + } if (enabled.sortable) { plugins.sort = sortPlugin; } @@ -431,6 +456,7 @@ export default function TableLabPage() { rowExpansion: false, groupedRows: false, rowIndex: false, + rowStatus: false, }); const baseData = useMemo(() => generateRows(rowCount), [rowCount]); diff --git a/apps/storybook/stories/RichTextEditor.stories.tsx b/apps/storybook/stories/RichTextEditor.stories.tsx index 3638d80f3260..03998771184d 100644 --- a/apps/storybook/stories/RichTextEditor.stories.tsx +++ b/apps/storybook/stories/RichTextEditor.stories.tsx @@ -2,10 +2,7 @@ import {useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; -import { - RichTextEditor, - RichTextView, -} from '@astryxdesign/lab'; +import {RichTextEditor, RichTextView} from '@astryxdesign/lab'; import type {EditorState} from 'lexical'; const meta: Meta = { diff --git a/apps/storybook/stories/TableRowStatus.stories.tsx b/apps/storybook/stories/TableRowStatus.stories.tsx new file mode 100644 index 000000000000..b53660aeaeb1 --- /dev/null +++ b/apps/storybook/stories/TableRowStatus.stories.tsx @@ -0,0 +1,101 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import type {Meta, StoryObj} from '@storybook/react'; +import { + Table, + useTableRowStatus, + proportional, + pixel, +} from '@astryxdesign/core/Table'; +import type {TableColumn, TableRowStatus} from '@astryxdesign/core/Table'; + +// ============================================================================= +// Sample Data +// ============================================================================= + +interface Job extends Record { + id: string; + name: string; + owner: string; + state: 'failed' | 'running' | 'queued' | 'succeeded'; +} + +const jobs: Job[] = [ + {id: 'j1', name: 'build-core', owner: 'Ava', state: 'failed'}, + {id: 'j2', name: 'lint', owner: 'Liam', state: 'running'}, + {id: 'j3', name: 'unit-tests', owner: 'Zoe', state: 'succeeded'}, + {id: 'j4', name: 'docsite-deploy', owner: 'Max', state: 'queued'}, + {id: 'j5', name: 'smoke-test', owner: 'Mia', state: 'succeeded'}, +]; + +const columns: TableColumn[] = [ + {key: 'name', header: 'Job', width: proportional(2)}, + {key: 'owner', header: 'Owner', width: pixel(120)}, + {key: 'state', header: 'State', width: pixel(120)}, +]; + +function jobStatus(job: Job): TableRowStatus | null { + switch (job.state) { + case 'failed': + return {color: 'error', icon: 'error', label: 'Failed'}; + case 'running': + return {color: 'warning', icon: 'warning', label: 'Running'}; + case 'queued': + return {color: 'gray', label: 'Queued'}; + default: + return null; // succeeded: no indicator + } +} + +const meta: Meta = { + title: 'Core/TableRowStatus', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * A small colored dot in a leading gutter column signals per-row status. + * Rows whose `getStatus` returns `null` (here: succeeded jobs) show no dot. + * Hover a dot to see its accessible label. + */ +export const Default: Story = { + render: () => { + const rowStatus = useTableRowStatus({getStatus: jobStatus}); + return ( +
+ ); + }, +}; + +/** + * Any CSS color works: here raw hex values instead of theme tokens. + */ +export const RawColors: Story = { + render: () => { + const rowStatus = useTableRowStatus({ + getStatus: job => + job.state === 'failed' + ? {color: '#dc2626', label: 'Failed'} + : job.state === 'running' + ? {color: '#f59e0b', label: 'Running'} + : null, + }); + return ( +
+ ); + }, +}; diff --git a/internal/vibe-tests/prompt-purity-test/ITERATE.md b/internal/vibe-tests/prompt-purity-test/ITERATE.md index dec3920caff0..e73f3c5e99b5 100644 --- a/internal/vibe-tests/prompt-purity-test/ITERATE.md +++ b/internal/vibe-tests/prompt-purity-test/ITERATE.md @@ -28,9 +28,9 @@ re-test a wording: - Path: `"$TMPDIR/astryx-purity/_loop/iterations-log.json"` (outside the repo, like all runtime artifacts). Create it on the first tick if absent: `{ "tick": 0, "champion": - "B-selfcheck", "history": [] }`. +"B-selfcheck", "history": [] }`. - Each `history[]` entry: `{ tick, expId, ranConditions, variantHashes, rates: { : - { veeredUncaught, caughtGivenVeered, finalPurityMean, n } }, note }`. +{ veeredUncaught, caughtGivenVeered, finalPurityMean, n } }, note }`. - "champion" = the best variant found so far (lowest `veeredUncaught`, tie-broken by higher `finalPurityMean`). Seed champion = `B-selfcheck`. @@ -41,12 +41,14 @@ re-test a wording: ## One tick ### 1. Read where we are + - Read `iterations-log.json`. Identify the current `champion`. - If `history` is empty, this is the seed tick: run `A-control`, `B-selfcheck`, `C-strong` together (exploration settings below) to establish the baseline and pick the first champion. Skip step 2 on the seed tick. ### 2. Author ONE new challenger (skip on the seed tick) + - Based on the last run's per-run detail in `purity-summary.json` (which markers survived in `veeredUncaught` runs, whether `ranDocRetrieval` was low, whether catches happened late), write ONE new variant wording that targets the observed failure mode. Ideas to @@ -59,10 +61,11 @@ re-test a wording: - Write it to a NEW file `variants/g-.md` (never overwrite A/B/C or a prior generation — history must stay reproducible). Add a matching entry to `conditions.json` `conditions[]`: `{ "id": "g-", "role": "candidate", - "variant": "g-" }`. +"variant": "g-" }`. - Do NOT test a wording whose text hash already appears in `history`. ### 3. Run (exploration = cheap, fast) + - Compare exactly THREE conditions so runs stay focused: control, champion, challenger. - Explore settings: `--prompts dd-1,tc-6 --reps 3`. - Start it in the BACKGROUND and arm the completion wake (dynamic `/loop`). Generate an @@ -76,11 +79,12 @@ EXP=$(node -e "console.log(require('crypto').randomBytes(4).toString('hex'))") echo "AGENT_LOOP_WAKE_purity {\"expId\":\"$EXP\",\"phase\":\"explore\"}" ) 2>&1 ``` - Launch via the Shell tool with `block_until_ms: 0` and `notify_on_output` on - `^AGENT_LOOP_WAKE_purity`. Also arm ONE long fallback heartbeat (e.g. `sleep 1800`) - per the loop skill. Then END THE TURN — do not block. +Launch via the Shell tool with `block_until_ms: 0` and `notify_on_output` on +`^AGENT_LOOP_WAKE_purity`. Also arm ONE long fallback heartbeat (e.g. `sleep 1800`) +per the loop skill. Then END THE TURN — do not block. ### 4. On wake — evaluate + - Read `"$TMPDIR/astryx-purity/$EXP/purity-summary.json"`. - Append a `history` entry (rates for each condition + variant hashes). - Compare challenger vs champion: challenger becomes the new champion if its @@ -89,6 +93,7 @@ EXP=$(node -e "console.log(require('crypto').randomBytes(4).toString('hex'))") `veeredUncaught`, `caughtGivenVeered`, purity — and whether the champion changed. ### 5. Decide: confirm, continue, or stop + - **Promote to confirmation** when the champion beats `A-control` in exploration (`veeredUncaught` lower AND purity higher). Run a CONFIRMATION: `--conditions A-control, --prompts dd-1,dd-5,wd-1,wd-4,tc-6,ps-1 --reps 7` diff --git a/internal/vibe-tests/prompt-purity-test/PLAN.md b/internal/vibe-tests/prompt-purity-test/PLAN.md index c236de8eb52e..bd267491ce2b 100644 --- a/internal/vibe-tests/prompt-purity-test/PLAN.md +++ b/internal/vibe-tests/prompt-purity-test/PLAN.md @@ -13,7 +13,7 @@ CSS / Tailwind / inline styles / hardcoded values instead of Astryx components + **self-verifies** its output and **catches itself** before finishing. > Does adding a post-generation self-check to the `astryx init` prompt make agents -> reliably (a) *not* leave raw CSS/Tailwind in the final file, and (b) when they do +> reliably (a) _not_ leave raw CSS/Tailwind in the final file, and (b) when they do > start to veer, **catch and correct it** — measured from the actual run trajectory? ## 2. What varies (independent variable) @@ -57,8 +57,8 @@ to verify — from the logging shim), and a transcript scan for "caught-in-reaso `stylex` styling system (the Astryx path), a logging `astryx` shim, then real `astryx init` + the variant splice. Writes `tasks/.json` + `purity-config-.json`. - `run-purity.ts` — runner. Shells out to the `cursor-agent` CLI headless (`--print - --output-format stream-json --model claude-opus-4-8-max-fast --force --trust - --workspace `), snapshots the `.tsx` on every change, persists the +--output-format stream-json --model claude-opus-4-8-max-fast --force --trust +--workspace `), snapshots the `.tsx` on every change, persists the streamed events to `transcript.jsonl`, records runtime facts to `run.json`. Bounded concurrency (`--concurrency`, default 3) + per-run timeout so it never starves interactive Cursor usage. diff --git a/internal/vibe-tests/prompt-purity-test/purity-aggregate.ts b/internal/vibe-tests/prompt-purity-test/purity-aggregate.ts index 8494461e5e3a..9c2255e5976b 100644 --- a/internal/vibe-tests/prompt-purity-test/purity-aggregate.ts +++ b/internal/vibe-tests/prompt-purity-test/purity-aggregate.ts @@ -27,21 +27,42 @@ import { type TokenUsage, } from './purity-eval.js'; -const DOC_RETRIEVAL_CMDS = new Set(['component', 'build', 'search', 'template', 'docs', 'swizzle']); +const DOC_RETRIEVAL_CMDS = new Set([ + 'component', + 'build', + 'search', + 'template', + 'docs', + 'swizzle', +]); function sandboxBase(out?: string): string { - return out || process.env.ASTRYX_PURITY_OUT || path.join(os.tmpdir(), 'astryx-purity'); + return ( + out || + process.env.ASTRYX_PURITY_OUT || + path.join(os.tmpdir(), 'astryx-purity') + ); } /** Wilson score interval for a binomial proportion (95% by default). */ -function wilson(successes: number, n: number, z = 1.96): {p: number; lo: number; hi: number} { - if (n === 0) {return {p: 0, lo: 0, hi: 0};} +function wilson( + successes: number, + n: number, + z = 1.96, +): {p: number; lo: number; hi: number} { + if (n === 0) { + return {p: 0, lo: 0, hi: 0}; + } const phat = successes / n; const z2 = z * z; const denom = 1 + z2 / n; const center = phat + z2 / (2 * n); const margin = z * Math.sqrt((phat * (1 - phat) + z2 / (4 * n)) / n); - return {p: phat, lo: Math.max(0, (center - margin) / denom), hi: Math.min(1, (center + margin) / denom)}; + return { + p: phat, + lo: Math.max(0, (center - margin) / denom), + hi: Math.min(1, (center + margin) / denom), + }; } interface Rate { @@ -56,7 +77,8 @@ function rate(items: T[], pick: (x: T) => boolean): Rate { const {p, lo, hi} = wilson(s, items.length); return {count: s, n: items.length, pct: p, lo, hi}; } -const fmtRate = (r: Rate) => `${(r.pct * 100).toFixed(0)}% [${(r.lo * 100).toFixed(0)}-${(r.hi * 100).toFixed(0)}] (${r.count}/${r.n})`; +const fmtRate = (r: Rate) => + `${(r.pct * 100).toFixed(0)}% [${(r.lo * 100).toFixed(0)}-${(r.hi * 100).toFixed(0)}] (${r.count}/${r.n})`; interface RunResult { taskId: string; @@ -77,7 +99,9 @@ interface RunResult { function readSnapshots(runDir: string): string[] { const snapDir = path.join(runDir, 'snapshots'); - if (!fs.existsSync(snapDir)) {return [];} + if (!fs.existsSync(snapDir)) { + return []; + } return fs .readdirSync(snapDir) .filter(f => f.endsWith('.tsx')) @@ -87,16 +111,25 @@ function readSnapshots(runDir: string): string[] { function readTranscriptText(runDir: string, cap = 200_000): string { const p = path.join(runDir, 'transcript.jsonl'); - if (!fs.existsSync(p)) {return '';} + if (!fs.existsSync(p)) { + return ''; + } let text = ''; for (const line of fs.readFileSync(p, 'utf-8').split('\n')) { - if (!line.trim()) {continue;} + if (!line.trim()) { + continue; + } try { - const ev = JSON.parse(line) as {message?: {content?: unknown}; text?: unknown}; + const ev = JSON.parse(line) as { + message?: {content?: unknown}; + text?: unknown; + }; const content = ev?.message?.content; if (Array.isArray(content)) { for (const block of content as Array<{type?: string; text?: string}>) { - if (block?.type === 'text' && typeof block.text === 'string') {text += block.text + '\n';} + if (block?.type === 'text' && typeof block.text === 'string') { + text += block.text + '\n'; + } } } else if (typeof ev?.text === 'string') { text += ev.text + '\n'; @@ -104,7 +137,9 @@ function readTranscriptText(runDir: string, cap = 200_000): string { } catch { /* skip malformed line */ } - if (text.length > cap) {break;} + if (text.length > cap) { + break; + } } return text; } @@ -113,8 +148,13 @@ function subcommand(argv: string[]): string | null { return argv.find(a => !a.startsWith('-')) ?? null; } -function readInvocationLog(logPath: string): {commands: string[]; subs: string[]} { - if (!fs.existsSync(logPath)) {return {commands: [], subs: []};} +function readInvocationLog(logPath: string): { + commands: string[]; + subs: string[]; +} { + if (!fs.existsSync(logPath)) { + return {commands: [], subs: []}; + } const entries = fs .readFileSync(logPath, 'utf-8') .trim() @@ -130,7 +170,9 @@ function readInvocationLog(logPath: string): {commands: string[]; subs: string[] .filter((e): e is {argv: string[]} => e !== null); return { commands: entries.map(e => e.argv.join(' ')), - subs: entries.map(e => subcommand(e.argv)).filter((s): s is string => s !== null), + subs: entries + .map(e => subcommand(e.argv)) + .filter((s): s is string => s !== null), }; } @@ -147,9 +189,15 @@ interface TaskRecord { function evaluateRun(task: TaskRecord): RunResult { const versions = readSnapshots(task.runDir); // Fallback: if no snapshots were captured but a final file exists, classify that. - if (versions.length === 0 && task.outputFile && fs.existsSync(task.outputFile)) { + if ( + versions.length === 0 && + task.outputFile && + fs.existsSync(task.outputFile) + ) { const finalContent = fs.readFileSync(task.outputFile, 'utf-8'); - if (finalContent.trim()) {versions.push(finalContent);} + if (finalContent.trim()) { + versions.push(finalContent); + } } const transcriptText = readTranscriptText(task.runDir); const cls = classifyTimeline(versions, {transcriptText}); @@ -184,7 +232,10 @@ function evaluateRun(task: TaskRecord): RunResult { } } if (usage && estCostUSD == null) { - estCostUSD = estimateCostUSD(usage, model === 'unknown' ? 'default' : model); + estCostUSD = estimateCostUSD( + usage, + model === 'unknown' ? 'default' : model, + ); } return { @@ -207,7 +258,9 @@ function evaluateRun(task: TaskRecord): RunResult { function conditionRuns(condDir: string): RunResult[] { const tasksDir = path.join(condDir, 'tasks'); - if (!fs.existsSync(tasksDir)) {return [];} + if (!fs.existsSync(tasksDir)) { + return []; + } return fs .readdirSync(tasksDir) .filter(f => f.endsWith('.json')) @@ -237,7 +290,9 @@ function costOf(runs: RunResult[]): CostBlock { let estCostUSD = 0; let n = 0; for (const r of runs) { - if (!r.usage) {continue;} + if (!r.usage) { + continue; + } usage = addUsage(usage, r.usage); estCostUSD += r.estCostUSD ?? 0; n++; @@ -256,9 +311,13 @@ function costOf(runs: RunResult[]): CostBlock { function costByPrompt(runs: RunResult[]): Record { const groups: Record = {}; - for (const r of runs) {(groups[r.basePromptId] ??= []).push(r);} + for (const r of runs) { + (groups[r.basePromptId] ??= []).push(r); + } const out: Record = {}; - for (const [pid, rs] of Object.entries(groups)) {out[pid] = costOf(rs);} + for (const [pid, rs] of Object.entries(groups)) { + out[pid] = costOf(rs); + } return out; } @@ -270,13 +329,17 @@ function main() { }; const expId = get('--experiment'); if (!expId) { - console.error('Usage: purity-aggregate.ts --experiment [--out ]'); + console.error( + 'Usage: purity-aggregate.ts --experiment [--out ]', + ); process.exit(1); } const expDir = path.join(sandboxBase(get('--out')), expId); const configPath = path.join(expDir, 'purity-config.json'); if (!fs.existsSync(configPath)) { - console.error(`No config at ${configPath}. Run setup-purity.mjs (and run-purity.ts) first.`); + console.error( + `No config at ${configPath}. Run setup-purity.mjs (and run-purity.ts) first.`, + ); process.exit(1); } const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); @@ -302,7 +365,9 @@ function main() { }; console.log(`\nPrompt Purity — experiment ${expId}`); - console.log(`Funnel over runs that produced output. Rates carry a Wilson 95% CI.\n`); + console.log( + `Funnel over runs that produced output. Rates carry a Wilson 95% CI.\n`, + ); console.log( `${'condition'.padEnd(14)} ${'completed'.padEnd(16)} ${'neverVeered'.padEnd(20)} ${'caught|veered'.padEnd(20)} ${'veeredUncaught'.padEnd(20)} ${'ranCLI'.padEnd(16)} purity`, ); @@ -362,9 +427,13 @@ function main() { // ── Comparison vs control ────────────────────────────────────────── const base = perCondition[control]; if (base) { - console.log(`\nvs control (${control}) — lower veeredUncaught + higher purity is better:\n`); + console.log( + `\nvs control (${control}) — lower veeredUncaught + higher purity is better:\n`, + ); for (const cond of config.conditions as string[]) { - if (cond === control) {continue;} + if (cond === control) { + continue; + } const c = perCondition[cond]; const vuDelta = (c.veeredUncaught.pct - base.veeredUncaught.pct) * 100; const purDelta = c.finalPurityMean - base.finalPurityMean; @@ -379,7 +448,9 @@ function main() { const totals = emptyUsage(); let totalCost = 0; let totalRuns = 0; - console.log(`\nCost — exact token counts; $ is an ESTIMATE (per-model table, cache-read priced low):\n`); + console.log( + `\nCost — exact token counts; $ is an ESTIMATE (per-model table, cache-read priced low):\n`, + ); console.log( `${'condition'.padEnd(14)} ${'runs'.padEnd(5)} ${'input'.padEnd(12)} ${'output'.padEnd(12)} ${'cacheRead'.padEnd(14)} ${'cacheWrite'.padEnd(13)} ${'avgOut'.padEnd(8)} est$`, ); @@ -400,7 +471,9 @@ function main() { console.log( `${'TOTAL'.padEnd(14)} ${String(totalRuns).padEnd(5)} ${fmtNum(totals.inputTokens).padEnd(12)} ${fmtNum(totals.outputTokens).padEnd(12)} ${fmtNum(totals.cacheReadTokens).padEnd(14)} ${fmtNum(totals.cacheWriteTokens).padEnd(13)} ${''.padEnd(8)} $${totalCost.toFixed(2)}`, ); - console.log(`\n($ is an estimate — on a Cursor plan real billing may be request-based; token counts above are exact.)`); + console.log( + `\n($ is an estimate — on a Cursor plan real billing may be request-based; token counts above are exact.)`, + ); summary.cost = { total: { @@ -417,7 +490,9 @@ function main() { const outPath = path.join(expDir, 'purity-summary.json'); fs.writeFileSync(outPath, JSON.stringify(summary, null, 2)); console.log(`\nSummary: ${outPath}`); - console.log(`Read: a variant wins when its veeredUncaught CI clears control's and purity is higher, across K>=5 reps.\n`); + console.log( + `Read: a variant wins when its veeredUncaught CI clears control's and purity is higher, across K>=5 reps.\n`, + ); } main(); diff --git a/internal/vibe-tests/prompt-purity-test/purity-eval.ts b/internal/vibe-tests/prompt-purity-test/purity-eval.ts index 75270c642fef..60de5921e697 100644 --- a/internal/vibe-tests/prompt-purity-test/purity-eval.ts +++ b/internal/vibe-tests/prompt-purity-test/purity-eval.ts @@ -64,11 +64,14 @@ export interface Classification { /** Pull the contents of className="..." / '...' / {`...`} literals. */ function extractClassNameLiterals(code: string): string[] { const out: string[] = []; - const re = /className\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([^`]*)`\s*\}|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\})/g; + const re = + /className\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([^`]*)`\s*\}|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\})/g; let m: RegExpExecArray | null; while ((m = re.exec(code)) !== null) { const val = m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5]; - if (val) {out.push(val);} + if (val) { + out.push(val); + } } return out; } @@ -83,7 +86,9 @@ function countTailwindTokens(code: string): {count: number; example: string} { for (const tok of literal.split(/\s+/).filter(Boolean)) { if (TW_TOKEN.test(tok)) { count++; - if (!example) {example = tok;} + if (!example) { + example = tok; + } } } } @@ -98,13 +103,20 @@ interface Detector { detect: (code: string) => {count: number; example: string}; } -function regexDetector(type: VeerType, severity: Severity, re: RegExp): Detector { +function regexDetector( + type: VeerType, + severity: Severity, + re: RegExp, +): Detector { return { type, severity, detect: (code: string) => { const matches = code.match(re) || []; - return {count: matches.length, example: matches[0]?.trim().slice(0, 60) ?? ''}; + return { + count: matches.length, + example: matches[0]?.trim().slice(0, 60) ?? '', + }; }, }; } @@ -112,9 +124,17 @@ function regexDetector(type: VeerType, severity: Severity, re: RegExp): Detector const DETECTORS: Detector[] = [ regexDetector('className', 'hard', /\bclassName\s*=/g), regexDetector('inlineStyle', 'hard', /\bstyle\s*=\s*\{\{/g), - regexDetector('cssImport', 'hard', /(?:import[^;\n]*["'][^"']+\.css["']|@apply\b|@import\b)/g), + regexDetector( + 'cssImport', + 'hard', + /(?:import[^;\n]*["'][^"']+\.css["']|@apply\b|@import\b)/g, + ), {type: 'tailwind', severity: 'hard', detect: countTailwindTokens}, - regexDetector('hardcodedColor', 'soft', /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla)\s*\(/g), + regexDetector( + 'hardcodedColor', + 'soft', + /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla)\s*\(/g, + ), regexDetector('hardcodedSize', 'soft', /\b\d{2,}(?:px|rem)\b/g), ]; @@ -123,7 +143,9 @@ export function detectMarkers(code: string): MarkerHit[] { const hits: MarkerHit[] = []; for (const d of DETECTORS) { const {count, example} = d.detect(code); - if (count > 0) {hits.push({type: d.type, severity: d.severity, count, example});} + if (count > 0) { + hits.push({type: d.type, severity: d.severity, count, example}); + } } return hits; } @@ -148,7 +170,9 @@ const PENALTY: Record = { /** 0-100 purity of a single code string (100 = pure Astryx, 0 = drowning in raw CSS/TW). */ export function purityScore(code: string): number { - if (!code || !code.trim()) {return 0;} + if (!code || !code.trim()) { + return 0; + } let penalty = 0; for (const h of detectMarkers(code)) { const {weight, cap} = PENALTY[h.type]; @@ -177,7 +201,9 @@ export function classifyTimeline( const final = versions.length ? versions[versions.length - 1] : ''; const finalMarkers = detectMarkers(final); - const finalHardCount = finalMarkers.filter(h => h.severity === 'hard').reduce((n, h) => n + h.count, 0); + const finalHardCount = finalMarkers + .filter(h => h.severity === 'hard') + .reduce((n, h) => n + h.count, 0); const finalClean = !noOutput && finalHardCount === 0; const finalPurity = purityScore(final); @@ -219,7 +245,9 @@ export async function gradedQuality(code: string): Promise { getAverageScore?: (score: unknown) => number; }; const score = mod.evaluate(code, 'astryx'); - return typeof mod.getAverageScore === 'function' ? mod.getAverageScore(score) : null; + return typeof mod.getAverageScore === 'function' + ? mod.getAverageScore(score) + : null; } catch { return null; } @@ -257,9 +285,24 @@ export interface ModelPricing { /** USD per 1M tokens. Estimates — edit freely; cache-read is intentionally cheap. */ export const PRICING: Record = { // Opus 4.x class - 'claude-opus-4-8-max-fast': {inputPerM: 15, outputPerM: 75, cacheReadPerM: 1.5, cacheWritePerM: 18.75}, - 'claude-opus-4-8-thinking-max-fast': {inputPerM: 15, outputPerM: 75, cacheReadPerM: 1.5, cacheWritePerM: 18.75}, - default: {inputPerM: 15, outputPerM: 75, cacheReadPerM: 1.5, cacheWritePerM: 18.75}, + 'claude-opus-4-8-max-fast': { + inputPerM: 15, + outputPerM: 75, + cacheReadPerM: 1.5, + cacheWritePerM: 18.75, + }, + 'claude-opus-4-8-thinking-max-fast': { + inputPerM: 15, + outputPerM: 75, + cacheReadPerM: 1.5, + cacheWritePerM: 18.75, + }, + default: { + inputPerM: 15, + outputPerM: 75, + cacheReadPerM: 1.5, + cacheWritePerM: 18.75, + }, }; export function pricingFor(model: string): ModelPricing { @@ -277,18 +320,26 @@ export function estimateCostUSD(usage: TokenUsage, model: string): number { ); } -const numOrNull = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null); +const numOrNull = (v: unknown): number | null => + typeof v === 'number' && Number.isFinite(v) ? v : null; /** * Parse the last `type:"result"` line out of a cursor-agent stream-json transcript. * Tolerant to camelCase (cursor-agent) and snake_case (Claude-style) field names. */ export function parseResultFromText(text: string): AgentResult | null { - if (!text) {return null;} + if (!text) { + return null; + } const lines = text.split('\n'); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; - if (!line.includes('"type":"result"') && !line.includes('"type": "result"')) {continue;} + if ( + !line.includes('"type":"result"') && + !line.includes('"type": "result"') + ) { + continue; + } try { const ev = JSON.parse(line) as { type?: string; @@ -298,20 +349,32 @@ export function parseResultFromText(text: string): AgentResult | null { duration_api_ms?: number; usage?: Record; }; - if (ev?.type !== 'result') {continue;} + if (ev?.type !== 'result') { + continue; + } const u: Record = ev.usage ?? {}; const usage: TokenUsage = { inputTokens: numOrNull(u.inputTokens ?? u.input_tokens) ?? 0, outputTokens: numOrNull(u.outputTokens ?? u.output_tokens) ?? 0, - cacheReadTokens: numOrNull(u.cacheReadTokens ?? u.cache_read_input_tokens ?? u.cacheReadInputTokens) ?? 0, + cacheReadTokens: + numOrNull( + u.cacheReadTokens ?? + u.cache_read_input_tokens ?? + u.cacheReadInputTokens, + ) ?? 0, cacheWriteTokens: - numOrNull(u.cacheWriteTokens ?? u.cache_creation_input_tokens ?? u.cacheWriteInputTokens) ?? 0, + numOrNull( + u.cacheWriteTokens ?? + u.cache_creation_input_tokens ?? + u.cacheWriteInputTokens, + ) ?? 0, }; return { subtype: ev.subtype ?? null, isError: !!ev.is_error, durationMs: numOrNull(ev.duration_ms), - apiDurationMs: numOrNull(ev.duration_api_ms) ?? numOrNull(ev.duration_ms), + apiDurationMs: + numOrNull(ev.duration_api_ms) ?? numOrNull(ev.duration_ms), usage, }; } catch { @@ -330,7 +393,12 @@ export function parseResultEvent(transcriptPath: string): AgentResult | null { } export function emptyUsage(): TokenUsage { - return {inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0}; + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; } export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage { diff --git a/internal/vibe-tests/prompt-purity-test/run-purity.ts b/internal/vibe-tests/prompt-purity-test/run-purity.ts index 7d3332a4509d..8ea67fa5c3ae 100644 --- a/internal/vibe-tests/prompt-purity-test/run-purity.ts +++ b/internal/vibe-tests/prompt-purity-test/run-purity.ts @@ -30,17 +30,27 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import {spawn, spawnSync} from 'node:child_process'; -import {parseResultFromText, estimateCostUSD, type TokenUsage, type AgentResult} from './purity-eval.js'; +import { + parseResultFromText, + estimateCostUSD, + type TokenUsage, + type AgentResult, +} from './purity-eval.js'; const DEFAULT_MODEL = 'claude-opus-4-8-max-fast'; // Opus 4.8 1M Max Fast const DEFAULT_AGENT = 'cursor-agent'; const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; const ensureDir = (d: string) => fs.mkdirSync(d, {recursive: true}); -const writeJson = (p: string, o: unknown) => fs.writeFileSync(p, JSON.stringify(o, null, 2)); +const writeJson = (p: string, o: unknown) => + fs.writeFileSync(p, JSON.stringify(o, null, 2)); function sandboxBase(out?: string): string { - return out || process.env.ASTRYX_PURITY_OUT || path.join(os.tmpdir(), 'astryx-purity'); + return ( + out || + process.env.ASTRYX_PURITY_OUT || + path.join(os.tmpdir(), 'astryx-purity') + ); } interface TaskFile { @@ -84,9 +94,13 @@ function startSnapshotter(targetFile: string, snapDir: string) { const capture = () => { try { - if (!fs.existsSync(targetFile)) {return;} + if (!fs.existsSync(targetFile)) { + return; + } const content = fs.readFileSync(targetFile, 'utf-8'); - if (content === last) {return;} + if (content === last) { + return; + } last = content; const name = `${String(seq++).padStart(4, '0')}-${Date.now()}.tsx`; fs.writeFileSync(path.join(snapDir, name), content); @@ -101,8 +115,12 @@ function startSnapshotter(targetFile: string, snapDir: string) { let watcher: fs.FSWatcher | null = null; try { watcher = fs.watch(dir, (_event, filename) => { - if (filename && filename.toString() !== base) {return;} - if (debounce) {clearTimeout(debounce);} + if (filename && filename.toString() !== base) { + return; + } + if (debounce) { + clearTimeout(debounce); + } debounce = setTimeout(capture, 80); }); } catch { @@ -112,7 +130,9 @@ function startSnapshotter(targetFile: string, snapDir: string) { return { stop: () => { - if (debounce) {clearTimeout(debounce);} + if (debounce) { + clearTimeout(debounce); + } clearInterval(poll); try { watcher?.close(); @@ -126,23 +146,35 @@ function startSnapshotter(targetFile: string, snapDir: string) { // ── Bounded concurrency pool ───────────────────────────────────────── -async function pool(items: T[], limit: number, worker: (item: T, i: number) => Promise): Promise { +async function pool( + items: T[], + limit: number, + worker: (item: T, i: number) => Promise, +): Promise { const results = new Array(items.length); let cursor = 0; - const runners = Array.from({length: Math.min(Math.max(1, limit), items.length || 1)}, async () => { - while (true) { - const i = cursor++; - if (i >= items.length) {break;} - results[i] = await worker(items[i], i); - } - }); + const runners = Array.from( + {length: Math.min(Math.max(1, limit), items.length || 1)}, + async () => { + while (true) { + const i = cursor++; + if (i >= items.length) { + break; + } + results[i] = await worker(items[i], i); + } + }, + ); await Promise.all(runners); return results; } // ── One run (spawn cursor-agent headless) ──────────────────────────── -function runOne(cfg: {model: string; agent: string; timeoutMs: number}, task: TaskFile): Promise { +function runOne( + cfg: {model: string; agent: string; timeoutMs: number}, + task: TaskFile, +): Promise { return new Promise(resolve => { ensureDir(task.runDir); const transcriptPath = path.join(task.runDir, 'transcript.jsonl'); @@ -154,11 +186,17 @@ function runOne(cfg: {model: string; agent: string; timeoutMs: number}, task: Ta let done = false; let transcriptBuf = ''; const finalize = (status: string, error: string | null) => { - if (done) {return;} + if (done) { + return; + } done = true; snap.stop(); - const snapshotCount = fs.existsSync(snapDir) ? fs.readdirSync(snapDir).filter(f => f.endsWith('.tsx')).length : 0; - const outputExists = fs.existsSync(task.outputFile) && fs.readFileSync(task.outputFile, 'utf-8').trim().length > 0; + const snapshotCount = fs.existsSync(snapDir) + ? fs.readdirSync(snapDir).filter(f => f.endsWith('.tsx')).length + : 0; + const outputExists = + fs.existsSync(task.outputFile) && + fs.readFileSync(task.outputFile, 'utf-8').trim().length > 0; // Parse the CLI's terminal `result` line for exact token usage + api duration, // so cost data is first-class in run.json (durable even if transcripts are pruned). const result: AgentResult | null = parseResultFromText(transcriptBuf); @@ -184,18 +222,25 @@ function runOne(cfg: {model: string; agent: string; timeoutMs: number}, task: Ta outputExists, }; writeJson(path.join(task.runDir, 'run.json'), runJson); - const tok = usage ? ` tok(out=${usage.outputTokens} cacheR=${usage.cacheReadTokens})` : ''; - console.log(` [${status.padEnd(13)}] ${task.condition}/${task.taskId} snapshots=${snapshotCount} output=${outputExists}${tok}${error ? ` (${error.slice(0, 80)})` : ''}`); + const tok = usage + ? ` tok(out=${usage.outputTokens} cacheR=${usage.cacheReadTokens})` + : ''; + console.log( + ` [${status.padEnd(13)}] ${task.condition}/${task.taskId} snapshots=${snapshotCount} output=${outputExists}${tok}${error ? ` (${error.slice(0, 80)})` : ''}`, + ); resolve(runJson); }; const args = [ '--print', - '--output-format', 'stream-json', - '--model', cfg.model, + '--output-format', + 'stream-json', + '--model', + cfg.model, '--force', // allow write/shell non-interactively '--trust', // trust the sandbox workspace (headless) - '--workspace', task.projectDir, + '--workspace', + task.projectDir, task.taskPrompt, ]; @@ -210,12 +255,16 @@ function runOne(cfg: {model: string; agent: string; timeoutMs: number}, task: Ta const out = fs.createWriteStream(transcriptPath); child.stdout.on('data', d => { const s = d.toString(); - if (transcriptBuf.length < 8_000_000) {transcriptBuf += s;} // keep in memory to parse the result line + if (transcriptBuf.length < 8_000_000) { + transcriptBuf += s; + } // keep in memory to parse the result line out.write(s); // persist full transcript to disk }); let stderr = ''; child.stderr.on('data', d => { - if (stderr.length < 4000) {stderr += d.toString();} + if (stderr.length < 4000) { + stderr += d.toString(); + } }); let killed = false; @@ -237,13 +286,21 @@ function runOne(cfg: {model: string; agent: string; timeoutMs: number}, task: Ta child.on('error', (e: NodeJS.ErrnoException) => { clearTimeout(timer); - finalize(e.code === 'ENOENT' ? 'startup_error' : 'exception', e.message || String(e)); + finalize( + e.code === 'ENOENT' ? 'startup_error' : 'exception', + e.message || String(e), + ); }); child.on('close', code => { clearTimeout(timer); out.end(); const status = killed ? 'timeout' : code === 0 ? 'finished' : 'error'; - finalize(status, status === 'finished' ? null : stderr.trim().slice(-300) || `exit ${code}`); + finalize( + status, + status === 'finished' + ? null + : stderr.trim().slice(-300) || `exit ${code}`, + ); }); }); } @@ -267,21 +324,34 @@ function parseArgs(argv: string[]) { model: get('--model') ?? DEFAULT_MODEL, agent: get('--agent') ?? DEFAULT_AGENT, timeoutMs: getInt('--timeout', DEFAULT_TIMEOUT_MS), - conditions: conditions ? conditions.split(',').map(s => s.trim()) : undefined, + conditions: conditions + ? conditions.split(',').map(s => s.trim()) + : undefined, dryRun: argv.includes('--dry-run'), }; } -function loadTasks(configDirs: Record, conditions: string[]): TaskFile[] { +function loadTasks( + configDirs: Record, + conditions: string[], +): TaskFile[] { const perCond: TaskFile[][] = []; for (const cond of conditions) { const condDir = configDirs[cond]; - if (!condDir) {throw new Error(`Condition "${cond}" not found in config.conditionDirs`);} + if (!condDir) { + throw new Error(`Condition "${cond}" not found in config.conditionDirs`); + } const tasksDir = path.join(condDir, 'tasks'); const arr: TaskFile[] = []; if (fs.existsSync(tasksDir)) { - for (const f of fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'))) { - arr.push(JSON.parse(fs.readFileSync(path.join(tasksDir, f), 'utf-8')) as TaskFile); + for (const f of fs + .readdirSync(tasksDir) + .filter(f => f.endsWith('.json'))) { + arr.push( + JSON.parse( + fs.readFileSync(path.join(tasksDir, f), 'utf-8'), + ) as TaskFile, + ); } } perCond.push(arr); @@ -290,7 +360,13 @@ function loadTasks(configDirs: Record, conditions: string[]): Ta // single condition monopolizes an early rate-limit window. const tasks: TaskFile[] = []; const max = Math.max(0, ...perCond.map(a => a.length)); - for (let i = 0; i < max; i++) {for (const arr of perCond) {if (i < arr.length) {tasks.push(arr[i]);}}} + for (let i = 0; i < max; i++) { + for (const arr of perCond) { + if (i < arr.length) { + tasks.push(arr[i]); + } + } + } return tasks; } @@ -298,12 +374,16 @@ function loadTasks(configDirs: Record, conditions: string[]): Ta function checkAgent(agent: string): boolean { const r = spawnSync(agent, ['status'], {encoding: 'utf-8'}); if (r.error) { - console.error(`Could not run "${agent}" (${r.error.message}). Install the Cursor CLI or pass --agent .`); + console.error( + `Could not run "${agent}" (${r.error.message}). Install the Cursor CLI or pass --agent .`, + ); return false; } const text = `${r.stdout ?? ''}${r.stderr ?? ''}`; if (!/logged in|Logged in/i.test(text) && !process.env.CURSOR_API_KEY) { - console.warn(`[warn] "${agent} status" does not report a login. Run "${agent} login" or set CURSOR_API_KEY.`); + console.warn( + `[warn] "${agent} status" does not report a login. Run "${agent} login" or set CURSOR_API_KEY.`, + ); } else { console.log(` Auth: ${text.trim().split('\n')[0] ?? 'ok'}`); } @@ -313,7 +393,9 @@ function checkAgent(agent: string): boolean { async function main() { const args = parseArgs(process.argv.slice(2)); if (!args.experiment) { - console.error('Usage: run-purity.ts --experiment [--out ] [--concurrency N] [--model ] [--dry-run]'); + console.error( + 'Usage: run-purity.ts --experiment [--out ] [--concurrency N] [--model ] [--dry-run]', + ); process.exit(1); } const expDir = path.join(sandboxBase(args.out), args.experiment); @@ -330,7 +412,9 @@ async function main() { console.log(` Conditions: ${conditions.join(', ')}`); console.log(` Tasks: ${tasks.length}`); console.log(` Model: ${args.model}`); - console.log(` Concurrency: ${args.concurrency}${args.dryRun ? ' (DRY RUN — no model calls)' : ''}\n`); + console.log( + ` Concurrency: ${args.concurrency}${args.dryRun ? ' (DRY RUN — no model calls)' : ''}\n`, + ); if (args.dryRun) { for (const task of tasks) { @@ -350,15 +434,32 @@ async function main() { return; } - if (!checkAgent(args.agent)) {process.exit(1);} + if (!checkAgent(args.agent)) { + process.exit(1); + } const t0 = Date.now(); - const results = await pool(tasks, args.concurrency, task => runOne({model: args.model, agent: args.agent, timeoutMs: args.timeoutMs}, task)); + const results = await pool(tasks, args.concurrency, task => + runOne( + {model: args.model, agent: args.agent, timeoutMs: args.timeoutMs}, + task, + ), + ); const byStatus: Record = {}; - for (const r of results) {byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;} - console.log(`\nDone in ${((Date.now() - t0) / 1000).toFixed(0)}s. Status: ${Object.entries(byStatus).map(([k, v]) => `${k}=${v}`).join(', ')}`); - console.log(`\nNext: npx tsx ${path.relative(process.cwd(), path.join(import.meta.dirname, 'purity-aggregate.ts'))} --experiment ${args.experiment}${args.out ? ` --out ${args.out}` : ''}\n`); + for (const r of results) { + byStatus[r.status] = (byStatus[r.status] ?? 0) + 1; + } + console.log( + `\nDone in ${((Date.now() - t0) / 1000).toFixed(0)}s. Status: ${Object.entries( + byStatus, + ) + .map(([k, v]) => `${k}=${v}`) + .join(', ')}`, + ); + console.log( + `\nNext: npx tsx ${path.relative(process.cwd(), path.join(import.meta.dirname, 'purity-aggregate.ts'))} --experiment ${args.experiment}${args.out ? ` --out ${args.out}` : ''}\n`, + ); } main().catch(e => { diff --git a/internal/vibe-tests/prompt-purity-test/smoke-test.ts b/internal/vibe-tests/prompt-purity-test/smoke-test.ts index 4568400f84a3..6a6be5f84b5e 100644 --- a/internal/vibe-tests/prompt-purity-test/smoke-test.ts +++ b/internal/vibe-tests/prompt-purity-test/smoke-test.ts @@ -34,7 +34,9 @@ function check(name: string, fn: () => void) { console.log(` PASS ${name}`); } catch (e) { failures++; - console.error(` FAIL ${name}\n ${e instanceof Error ? e.message : String(e)}`); + console.error( + ` FAIL ${name}\n ${e instanceof Error ? e.message : String(e)}`, + ); } } @@ -45,7 +47,9 @@ function softCheck(name: string, fn: () => void) { passed++; console.log(` PASS ${name}`); } catch (e) { - console.warn(` WARN ${name}\n ${e instanceof Error ? e.message : String(e)}`); + console.warn( + ` WARN ${name}\n ${e instanceof Error ? e.message : String(e)}`, + ); } } @@ -77,16 +81,29 @@ export default function App() { console.log('\nA. Veer/catch classifier (fabricated timelines)'); -check('detectMarkers finds className/inlineStyle/cssImport/tailwind/hardcodedColor in dirty code', () => { - const types = new Set(detectMarkers(DIRTY).map(h => h.type)); - for (const t of ['className', 'inlineStyle', 'cssImport', 'tailwind', 'hardcodedColor'] as const) { - assert.ok(types.has(t), `expected marker "${t}"`); - } -}); +check( + 'detectMarkers finds className/inlineStyle/cssImport/tailwind/hardcodedColor in dirty code', + () => { + const types = new Set(detectMarkers(DIRTY).map(h => h.type)); + for (const t of [ + 'className', + 'inlineStyle', + 'cssImport', + 'tailwind', + 'hardcodedColor', + ] as const) { + assert.ok(types.has(t), `expected marker "${t}"`); + } + }, +); check('detectMarkers finds nothing hard in pure Astryx code', () => { const hard = detectMarkers(PURE).filter(h => h.severity === 'hard'); - assert.equal(hard.length, 0, `unexpected hard markers: ${hard.map(h => h.type).join(', ')}`); + assert.equal( + hard.length, + 0, + `unexpected hard markers: ${hard.map(h => h.type).join(', ')}`, + ); }); check('purityScore: pure high (>90), dirty low (<50)', () => { @@ -96,7 +113,14 @@ check('purityScore: pure high (>90), dirty low (<50)', () => { check('neverVeered: single clean write', () => { const c = classifyTimeline([PURE]); - assert.ok(c.neverVeered && !c.everVeered && !c.caught && !c.veeredUncaught && !c.noOutput, JSON.stringify(c)); + assert.ok( + c.neverVeered && + !c.everVeered && + !c.caught && + !c.veeredUncaught && + !c.noOutput, + JSON.stringify(c), + ); }); check('caught: dirty then clean (veered, then fixed)', () => { @@ -118,74 +142,143 @@ check('noOutput: empty timeline', () => { assert.ok(c.noOutput && !c.everVeered && !c.neverVeered, JSON.stringify(c)); }); -check('caughtInReasoning: mentioned Tailwind but never wrote a hard marker', () => { - const c = classifyTimeline([PURE], {transcriptText: 'I considered using Tailwind here but Astryx has a component for it.'}); - assert.ok(c.mentionedVeerInReasoning && c.neverVeered && c.caughtInReasoning, JSON.stringify(c)); -}); +check( + 'caughtInReasoning: mentioned Tailwind but never wrote a hard marker', + () => { + const c = classifyTimeline([PURE], { + transcriptText: + 'I considered using Tailwind here but Astryx has a component for it.', + }); + assert.ok( + c.mentionedVeerInReasoning && c.neverVeered && c.caughtInReasoning, + JSON.stringify(c), + ); + }, +); // ── B. setup wiring ────────────────────────────────────────────────── -console.log('\nB. setup-purity.mjs wiring (real astryx init into a temp sandbox)'); +console.log( + '\nB. setup-purity.mjs wiring (real astryx init into a temp sandbox)', +); const smokeOut = fs.mkdtempSync(path.join(os.tmpdir(), 'purity-smoke-')); const expId = 'smoke' + crypto.randomBytes(2).toString('hex'); let setupOk = false; -check('setup-purity.mjs builds A-control + B-selfcheck for dd-1 (1 rep)', () => { - execFileSync( - process.execPath, - [ - path.join(EXP_DIR, 'setup-purity.mjs'), - '--exp', expId, - '--out', smokeOut, - '--conditions', 'A-control,B-selfcheck', - '--prompts', 'dd-1', - '--reps', '1', - ], - {stdio: 'pipe'}, - ); - assert.ok(fs.existsSync(path.join(smokeOut, expId, 'purity-config.json')), 'purity-config.json missing'); - setupOk = true; -}); - -const proj = (cond: string) => path.join(smokeOut, expId, cond, 'projects', 'dd-1'); +check( + 'setup-purity.mjs builds A-control + B-selfcheck for dd-1 (1 rep)', + () => { + execFileSync( + process.execPath, + [ + path.join(EXP_DIR, 'setup-purity.mjs'), + '--exp', + expId, + '--out', + smokeOut, + '--conditions', + 'A-control,B-selfcheck', + '--prompts', + 'dd-1', + '--reps', + '1', + ], + {stdio: 'pipe'}, + ); + assert.ok( + fs.existsSync(path.join(smokeOut, expId, 'purity-config.json')), + 'purity-config.json missing', + ); + setupOk = true; + }, +); + +const proj = (cond: string) => + path.join(smokeOut, expId, cond, 'projects', 'dd-1'); check('control AGENTS.md has NO self-check; candidate AGENTS.md HAS it', () => { assert.ok(setupOk, 'setup did not complete'); const a = fs.readFileSync(path.join(proj('A-control'), 'AGENTS.md'), 'utf-8'); - const b = fs.readFileSync(path.join(proj('B-selfcheck'), 'AGENTS.md'), 'utf-8'); - assert.ok(!a.includes('PURITY SELF-CHECK'), 'control should not contain the self-check block'); - assert.ok(b.includes('PURITY SELF-CHECK'), 'candidate should contain the self-check block'); - assert.ok(b.includes('re-read the file'), 'candidate should contain the B-selfcheck text'); -}); - -check('injected prompt recommends the Astryx xstyle/StyleX path (stylex detected)', () => { - assert.ok(setupOk, 'setup did not complete'); - const a = fs.readFileSync(path.join(proj('A-control'), 'AGENTS.md'), 'utf-8'); - assert.ok(/xstyle|StyleX/i.test(a), 'AGENTS.md should reference xstyle/StyleX (styling system should detect as stylex)'); + const b = fs.readFileSync( + path.join(proj('B-selfcheck'), 'AGENTS.md'), + 'utf-8', + ); + assert.ok( + !a.includes('PURITY SELF-CHECK'), + 'control should not contain the self-check block', + ); + assert.ok( + b.includes('PURITY SELF-CHECK'), + 'candidate should contain the self-check block', + ); + assert.ok( + b.includes('re-read the file'), + 'candidate should contain the B-selfcheck text', + ); }); -check('base package.json lists a StyleX compiler plugin (drives stylex detection)', () => { - assert.ok(setupOk, 'setup did not complete'); - const pkg = JSON.parse(fs.readFileSync(path.join(proj('A-control'), 'package.json'), 'utf-8')); - assert.ok(pkg.devDependencies?.['@stylexjs/babel-plugin'], 'missing @stylexjs/babel-plugin'); -}); +check( + 'injected prompt recommends the Astryx xstyle/StyleX path (stylex detected)', + () => { + assert.ok(setupOk, 'setup did not complete'); + const a = fs.readFileSync( + path.join(proj('A-control'), 'AGENTS.md'), + 'utf-8', + ); + assert.ok( + /xstyle|StyleX/i.test(a), + 'AGENTS.md should reference xstyle/StyleX (styling system should detect as stylex)', + ); + }, +); + +check( + 'base package.json lists a StyleX compiler plugin (drives stylex detection)', + () => { + assert.ok(setupOk, 'setup did not complete'); + const pkg = JSON.parse( + fs.readFileSync(path.join(proj('A-control'), 'package.json'), 'utf-8'), + ); + assert.ok( + pkg.devDependencies?.['@stylexjs/babel-plugin'], + 'missing @stylexjs/babel-plugin', + ); + }, +); check('logging astryx shim is installed', () => { assert.ok(setupOk, 'setup did not complete'); const shim = path.join(proj('A-control'), 'node_modules', '.bin', 'astryx'); assert.ok(fs.existsSync(shim), 'shim missing'); - assert.ok(fs.readFileSync(shim, 'utf-8').includes('.astryx-invocations.log'), 'shim does not log invocations'); + assert.ok( + fs.readFileSync(shim, 'utf-8').includes('.astryx-invocations.log'), + 'shim does not log invocations', + ); }); -check('no answer leak: expectedComponents never appears in the task prompt', () => { - assert.ok(setupOk, 'setup did not complete'); - const task = JSON.parse(fs.readFileSync(path.join(smokeOut, expId, 'B-selfcheck', 'tasks', 'dd-1.json'), 'utf-8')); - assert.ok(!task.taskPrompt.includes('expectedComponents'), 'taskPrompt leaks the expectedComponents key'); - for (const comp of task.expectedComponents ?? []) { - assert.ok(!task.taskPrompt.includes(comp), `taskPrompt leaks expected component "${comp}"`); - } -}); +check( + 'no answer leak: expectedComponents never appears in the task prompt', + () => { + assert.ok(setupOk, 'setup did not complete'); + const task = JSON.parse( + fs.readFileSync( + path.join(smokeOut, expId, 'B-selfcheck', 'tasks', 'dd-1.json'), + 'utf-8', + ), + ); + assert.ok( + !task.taskPrompt.includes('expectedComponents'), + 'taskPrompt leaks the expectedComponents key', + ); + for (const comp of task.expectedComponents ?? []) { + assert.ok( + !task.taskPrompt.includes(comp), + `taskPrompt leaks expected component "${comp}"`, + ); + } + }, +); // ── C. runner dry-run (best-effort) ────────────────────────────────── @@ -194,16 +287,38 @@ console.log('\nC. run-purity.ts --dry-run (headless pipeline; best-effort)'); softCheck('dry-run creates run.json with status "dry-run"', () => { assert.ok(setupOk, 'setup did not complete'); try { - execFileSync('npx', ['tsx', path.join(EXP_DIR, 'run-purity.ts'), '--experiment', expId, '--out', smokeOut, '--dry-run'], { - stdio: 'pipe', - cwd: EXP_DIR, - }); + execFileSync( + 'npx', + [ + 'tsx', + path.join(EXP_DIR, 'run-purity.ts'), + '--experiment', + expId, + '--out', + smokeOut, + '--dry-run', + ], + { + stdio: 'pipe', + cwd: EXP_DIR, + }, + ); } catch (e) { // Non-fatal: environment may not resolve `npx tsx` here. The classifier + wiring // checks are the load-bearing ones; surface this as a soft note instead. - throw new Error(`could not run dry-run via "npx tsx" (${e instanceof Error ? e.message : String(e)}) — run it manually to verify`, {cause: e}); + throw new Error( + `could not run dry-run via "npx tsx" (${e instanceof Error ? e.message : String(e)}) — run it manually to verify`, + {cause: e}, + ); } - const runJson = path.join(smokeOut, expId, 'A-control', 'runs', 'dd-1', 'run.json'); + const runJson = path.join( + smokeOut, + expId, + 'A-control', + 'runs', + 'dd-1', + 'run.json', + ); assert.ok(fs.existsSync(runJson), 'run.json not created by dry-run'); assert.equal(JSON.parse(fs.readFileSync(runJson, 'utf-8')).status, 'dry-run'); }); @@ -216,5 +331,7 @@ try { /* ignore */ } -console.log(`\n${failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`} (${passed} passed, ${failures} failed)\n`); +console.log( + `\n${failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`} (${passed} passed, ${failures} failed)\n`, +); process.exit(failures === 0 ? 0 : 1); diff --git a/packages/cli/src/types/template-api.d.ts b/packages/cli/src/types/template-api.d.ts index 85264febcf60..362f3ded67aa 100644 --- a/packages/cli/src/types/template-api.d.ts +++ b/packages/cli/src/types/template-api.d.ts @@ -15,4 +15,7 @@ export type { AstryxTemplate, } from '@astryxdesign/core/authoring'; -export {createPageTemplate, createBlockTemplate} from '@astryxdesign/core/authoring'; +export { + createPageTemplate, + createBlockTemplate, +} from '@astryxdesign/core/authoring'; diff --git a/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs new file mode 100644 index 000000000000..3e8d261aee16 --- /dev/null +++ b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('../../../../../core/src/docs-types').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'useTableRowStatus', + name: 'useTableRowStatus - Status Dots', + displayName: 'useTableRowStatus - Status Dots', + description: + 'A job table using useTableRowStatus to render a colored status dot (or icon) per row (failed / running / queued). Rows with no status show no dot.', + isReady: true, + aspectRatio: 16 / 9, + componentsUsed: ['Table'], +}; diff --git a/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx new file mode 100644 index 000000000000..21ab28b49173 --- /dev/null +++ b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx @@ -0,0 +1,59 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import { + Table, + useTableRowStatus, + proportional, + pixel, +} from '@astryxdesign/core/Table'; +import type {TableColumn, TableRowStatus} from '@astryxdesign/core/Table'; + +interface Job extends Record { + id: string; + name: string; + owner: string; + state: 'failed' | 'running' | 'queued' | 'succeeded'; +} + +const jobs: Job[] = [ + {id: 'j1', name: 'build-core', owner: 'Ava', state: 'failed'}, + {id: 'j2', name: 'lint', owner: 'Liam', state: 'running'}, + {id: 'j3', name: 'unit-tests', owner: 'Zoe', state: 'succeeded'}, + {id: 'j4', name: 'docsite-deploy', owner: 'Max', state: 'queued'}, + {id: 'j5', name: 'smoke-test', owner: 'Mia', state: 'succeeded'}, +]; + +const columns: TableColumn[] = [ + {key: 'name', header: 'Job', width: proportional(2)}, + {key: 'owner', header: 'Owner', width: pixel(120)}, + {key: 'state', header: 'State', width: pixel(120)}, +]; + +function jobStatus(job: Job): TableRowStatus | null { + switch (job.state) { + case 'failed': + return {color: 'error', icon: 'error', label: 'Failed'}; + case 'running': + return {color: 'warning', icon: 'warning', label: 'Running'}; + case 'queued': + return {color: 'gray', label: 'Queued'}; + default: + return null; // succeeded: no dot + } +} + +export default function TableRowStatusTable() { + const rowStatus = useTableRowStatus({getStatus: jobStatus}); + + return ( +
+ ); +} diff --git a/packages/cli/templates/themes/chocolate/chocolateTheme.ts b/packages/cli/templates/themes/chocolate/chocolateTheme.ts index cd567dde0488..aca744788724 100644 --- a/packages/cli/templates/themes/chocolate/chocolateTheme.ts +++ b/packages/cli/templates/themes/chocolate/chocolateTheme.ts @@ -44,8 +44,7 @@ export const chocolateTheme = defineTheme({ }, heading: { family: 'Fraunces', - fallbacks: - 'Georgia, "Times New Roman", Times, serif', + fallbacks: 'Georgia, "Times New Roman", Times, serif', weights: {3: 'bold', 4: 'bold'}, }, code: { @@ -188,12 +187,9 @@ export const chocolateTheme = defineTheme({ // ========================================================================= // Shadows — warm-toned // ========================================================================= - '--shadow-low': - '0 2px 4px #4a35200D, 0 4px 8px #4a35201A', - '--shadow-med': - '0 2px 4px #4a35200D, 0 4px 12px #4a35201A', - '--shadow-high': - '0 4px 6px #4a35201A, 0 12px 24px #4a352026', + '--shadow-low': '0 2px 4px #4a35200D, 0 4px 8px #4a35201A', + '--shadow-med': '0 2px 4px #4a35200D, 0 4px 12px #4a35201A', + '--shadow-high': '0 4px 6px #4a35201A, 0 12px 24px #4a352026', '--shadow-inset-hover': 'inset 0px 0px 0px 2px #8C592730', '--shadow-inset-selected': 'inset 0px 0px 0px 2px #8C592750', '--shadow-inset-success': 'inset 0px 0px 0px 2px #70990050', diff --git a/packages/cli/templates/themes/neutral/neutralTheme.ts b/packages/cli/templates/themes/neutral/neutralTheme.ts index c8aa5c15b1c8..d28c6f60a81e 100644 --- a/packages/cli/templates/themes/neutral/neutralTheme.ts +++ b/packages/cli/templates/themes/neutral/neutralTheme.ts @@ -39,19 +39,19 @@ import {neutralIconRegistry} from './icons'; const neutralSyntax = defineSyntaxTheme({ name: 'xds-neutral', tokens: { - keyword: ['#700084', '#efa8ff'], // purple T30/T80 - string: ['#005600', '#a6d2a2'], // green (sat T30 / pastel T80) - comment: ['#737373', '#a3a3a3'], // neutral - number: ['#6e3500', '#ffb37f'], // orange - function: ['#00458c', '#a0caff'], // blue T30/T80 H=255 - type: ['#700084', '#efa8ff'], // purple - variable: ['#171717', '#e5e5e5'], // near-black / near-white - operator: ['#737373', '#a3a3a3'], // neutral - constant: ['#6e3500', '#ffb37f'], // orange - tag: ['#89001a', '#ffaeaa'], // red - attribute: ['#584400', '#eec12f'], // yellow - property: ['#005348', '#83dac9'], // teal - punctuation: ['#a3a3a3', '#525252'],// neutral + keyword: ['#700084', '#efa8ff'], // purple T30/T80 + string: ['#005600', '#a6d2a2'], // green (sat T30 / pastel T80) + comment: ['#737373', '#a3a3a3'], // neutral + number: ['#6e3500', '#ffb37f'], // orange + function: ['#00458c', '#a0caff'], // blue T30/T80 H=255 + type: ['#700084', '#efa8ff'], // purple + variable: ['#171717', '#e5e5e5'], // near-black / near-white + operator: ['#737373', '#a3a3a3'], // neutral + constant: ['#6e3500', '#ffb37f'], // orange + tag: ['#89001a', '#ffaeaa'], // red + attribute: ['#584400', '#eec12f'], // yellow + property: ['#005348', '#83dac9'], // teal + punctuation: ['#a3a3a3', '#525252'], // neutral background: ['#fafafa', '#0a0a0a'], }, }); @@ -127,39 +127,39 @@ export const neutralTheme = defineTheme({ // All values use the OKLCH Neutral tonal palette (chroma=0). // ========================================================================= '--color-background-surface': ['#ffffff', '#262626'], - '--color-background-body': ['#f1f1f1', '#1b1b1b'], - '--color-background-card': ['#ffffff', '#1b1b1b'], + '--color-background-body': ['#f1f1f1', '#1b1b1b'], + '--color-background-card': ['#ffffff', '#1b1b1b'], '--color-background-popover': ['#ffffff', '#1b1b1b'], - '--color-background-muted': ['#f1f1f1', '#1b1b1b'], + '--color-background-muted': ['#f1f1f1', '#1b1b1b'], // Accent + neutral surface tints (sit alongside backgrounds) - '--color-accent': ['#262626', '#ebebeb'], + '--color-accent': ['#262626', '#ebebeb'], '--color-accent-muted': ['#f1f1f1', '#262626'], - '--color-neutral': ['#0000000F', '#FFFFFF1A'], + '--color-neutral': ['#0000000F', '#FFFFFF1A'], // Overlays (modal scrims, hover/pressed tints) - '--color-overlay': ['#00000080', '#000000CC'], - '--color-overlay-hover': ['#0000000D', '#FFFFFF0D'], + '--color-overlay': ['#00000080', '#000000CC'], + '--color-overlay-hover': ['#0000000D', '#FFFFFF0D'], '--color-overlay-pressed': ['#0000001A', '#FFFFFF1A'], // Text - '--color-text-primary': ['#171717', '#fafafa'], + '--color-text-primary': ['#171717', '#fafafa'], '--color-text-secondary': ['#737373', '#a3a3a3'], - '--color-text-disabled': ['#a3a3a3', '#525252'], - '--color-text-accent': ['#262626', '#ebebeb'], - '--color-on-dark': '#ffffff', - '--color-on-light': '#171717', + '--color-text-disabled': ['#a3a3a3', '#525252'], + '--color-text-accent': ['#262626', '#ebebeb'], + '--color-on-dark': '#ffffff', + '--color-on-light': '#171717', // Contrast: neutral accent is near-black (L) / near-white (D) - '--color-on-accent': ['#ffffff', '#171717'], + '--color-on-accent': ['#ffffff', '#171717'], '--color-on-success': ['#ffffff', '#171717'], - '--color-on-error': ['#ffffff', '#171717'], + '--color-on-error': ['#ffffff', '#171717'], '--color-on-warning': '#171717', // Icon - '--color-icon-accent': ['#262626', '#ebebeb'], - '--color-icon-primary': ['#171717', '#fafafa'], + '--color-icon-accent': ['#262626', '#ebebeb'], + '--color-icon-primary': ['#171717', '#fafafa'], '--color-icon-secondary': ['#737373', '#a3a3a3'], - '--color-icon-disabled': ['#a3a3a3', '#525252'], + '--color-icon-disabled': ['#a3a3a3', '#525252'], // Status / Sentiment — dark mode follows the issue #2150 rubric: // @@ -372,8 +372,8 @@ export const neutralTheme = defineTheme({ // ========================================================================= button: { 'variant:destructive': { - backgroundColor: 'var(--color-error-muted)', // locked pastel red bg - color: 'var(--color-error)', // locked T30 red — matches banner/input error text + backgroundColor: 'var(--color-error-muted)', // locked pastel red bg + color: 'var(--color-error)', // locked T30 red — matches banner/input error text }, }, diff --git a/packages/core/src/Banner/Banner.tsx b/packages/core/src/Banner/Banner.tsx index fad2037ff742..04c7efed0f99 100644 --- a/packages/core/src/Banner/Banner.tsx +++ b/packages/core/src/Banner/Banner.tsx @@ -474,8 +474,16 @@ export function Banner({
+ ); +} + +describe('useTableRowStatus', () => { + it('prepends a narrow status column with an empty header', () => { + render(); + const headers = screen.getAllByRole('columnheader'); + // Status column is first; its header is empty. + expect(headers[0]).toHaveAttribute('data-column-key', '__rowStatus'); + expect(headers[0].textContent).toBe(''); + }); + + it('renders a labeled dot for rows with a status', () => { + render(); + expect(screen.getByRole('img', {name: 'Error'})).toBeInTheDocument(); + expect(screen.getByRole('img', {name: 'Warning'})).toBeInTheDocument(); + }); + + it('renders a dot (not an icon) by default', () => { + render(); + // Default (no icon) renders a plain colored dot: no svg in the indicator. + const dot = screen.getByRole('img', {name: 'Error'}); + expect(dot.querySelector('svg')).toBeNull(); + }); + + it('renders no indicator for rows returning null', () => { + render(); + const rows = screen.getAllByRole('row'); + // rows[2] is Bob (state ok): no status indicator in his status cell. + const bob = rows[2]; + expect(within(bob).getByText('Bob')).toBeInTheDocument(); + expect(within(bob).queryByRole('img')).not.toBeInTheDocument(); + }); + + it('maps a semantic color name to its icon color token', () => { + render(); + const errorDot = screen.getByRole('img', {name: 'Error'}); + // 'red' resolves to var(--color-icon-red); StyleX emits it on the inline + // style of the inner dot element. + const dot = errorDot.querySelector('span'); + expect(dot?.getAttribute('style')).toContain('--color-icon-red'); + }); + + it('passes through a raw CSS color as an escape hatch', () => { + render( + + item.state === 'error' ? {color: 'rgb(1, 2, 3)', label: 'Raw'} : null + } + />, + ); + const indicator = screen.getByRole('img', {name: 'Raw'}); + const dot = indicator.querySelector('span'); + expect(dot?.getAttribute('style')).toContain('rgb(1, 2, 3)'); + }); + + it('renders an icon as the status signifier when icon is provided', () => { + render( + + item.state === 'error' + ? {color: 'red', icon: 'error', label: 'Error'} + : null + } + />, + ); + // Icon-mode still exposes the accessible label via role=img. + const indicator = screen.getByRole('img', {name: 'Error'}); + expect(indicator).toBeInTheDocument(); + // An SVG icon is rendered inside the indicator (dot mode has no svg). + expect(indicator.querySelector('svg')).not.toBeNull(); + }); + + it('exposes the required label as the accessible name in dot mode', () => { + render( + + item.state === 'error' ? {color: 'red', label: 'Error'} : null + } + />, + ); + // Label is required, so every dot is announced via role=img with its name. + const indicator = screen.getByRole('img', {name: 'Error'}); + expect(indicator).toBeInTheDocument(); + // Dot mode renders no svg (that is icon mode). + expect(indicator.querySelector('svg')).toBeNull(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('renders the status header with empty data and no indicators', () => { + render(); + const headers = screen.getAllByRole('columnheader'); + expect(headers[0]).toHaveAttribute('data-column-key', '__rowStatus'); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx new file mode 100644 index 000000000000..870c0d41916e --- /dev/null +++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx @@ -0,0 +1,189 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useTableRowStatus.tsx + * @input React, StyleX, Icon, Table types + * @output Exports useTableRowStatus hook + config type + * @position Row-status plugin; consumed by Table via plugins prop + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/core/src/Table/index.ts (exports) + */ + +import {useMemo} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {Icon, type IconColor, type IconName} from '../../../Icon'; +import {Tooltip} from '../../../Tooltip'; +import type {TableColumn, TablePlugin} from '../../types'; + +/** + * Semantic status colors, resolved to the design system's icon color tokens. + * Prefer these over raw CSS so status colors stay consistent with the theme. + */ +export type TableRowStatusColor = + | 'accent' + | 'success' + | 'error' + | 'warning' + | 'red' + | 'orange' + | 'green' + | 'yellow' + | 'blue' + | 'gray'; + +const SEMANTIC_COLORS: Record = { + accent: 'var(--color-icon-accent)', + success: 'var(--color-icon-green)', + error: 'var(--color-icon-red)', + warning: 'var(--color-icon-orange)', + red: 'var(--color-icon-red)', + orange: 'var(--color-icon-orange)', + green: 'var(--color-icon-green)', + yellow: 'var(--color-icon-yellow)', + blue: 'var(--color-icon-blue)', + gray: 'var(--color-icon-gray)', +}; + +/** Icon colors that map cleanly from a semantic status color. */ +const ICON_COLOR_BY_STATUS: Record = { + accent: 'accent', + success: 'success', + error: 'error', + warning: 'warning', + red: 'red', + orange: 'warning', + green: 'green', + yellow: 'warning', + blue: 'blue', + gray: 'gray', +}; + +/** + * A row's status indicator. `color` accepts a semantic status color + * (mapped to a theme token) or any raw CSS color string as an escape hatch. + * By default the plugin renders a colored status dot. Provide `icon` to signal + * status by shape as well as color, which is more accessible when several + * statuses coexist. `label` is required so the status is never conveyed by + * color alone — it names the indicator for assistive technology and shows on + * hover. Return `null` for rows with no status. + */ +export interface TableRowStatus { + /** Semantic status color (preferred) or a raw CSS color string. */ + color: TableRowStatusColor | (string & {}); + /** Optional icon rendered as the signifier instead of the dot (shape as an a11y differentiator). */ + icon?: IconName; + /** + * Accessible name for the status, announced to assistive technology and + * shown in a tooltip on hover. Required: a status must never be conveyed by + * color alone. + */ + label: string; +} + +/** Configuration for {@link useTableRowStatus}. */ +export interface UseTableRowStatusConfig> { + /** + * Derive the status indicator for a row. Return `null` for no indicator. + * Memoize with `useCallback` for a stable plugin identity across renders. + * + * @example + * ``` + * getStatus: row => + * row.hasError ? {color: 'error', icon: 'error', label: 'Error'} : null + * ``` + */ + getStatus: (item: T) => TableRowStatus | null; +} + +// The status column holds a small centered dot (or an icon when provided). +// A fixed narrow width keeps every row's indicator aligned in one gutter. +const STATUS_COLUMN_WIDTH = {type: 'pixel' as const, value: 28}; + +/** Resolve a semantic color name to a token, or pass a raw CSS color through. */ +function resolveColor(color: string): string { + return (SEMANTIC_COLORS as Record)[color] ?? color; +} + +const styles = stylex.create({ + // Centers the dot or icon within the narrow status column. + wrap: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + dot: (color: string) => ({ + width: '8px', + height: '8px', + borderRadius: '50%', + backgroundColor: color, + flexShrink: 0, + }), +}); + +/** + * Returns a {@link TablePlugin} that prepends a narrow column signaling per-row + * status: a colored status dot by default, or an icon when `icon` is provided + * (shape + color is more accessible than color alone). + * + * @example + * ``` + * const rowStatus = useTableRowStatus({ + * getStatus: row => + * row.state === 'error' + * ? {color: 'error', icon: 'error', label: 'Error'} + * : null, + * }); + *
; + * ``` + */ +export function useTableRowStatus>( + config: UseTableRowStatusConfig, +): TablePlugin { + const {getStatus} = config; + + return useMemo( + (): TablePlugin => ({ + transformColumns(columns) { + const statusColumn: TableColumn = { + key: '__rowStatus', + header: '', + width: STATUS_COLUMN_WIDTH, + resizable: false, + renderCell: (item: T) => { + const status = getStatus(item); + if (!status) { + return null; + } + const signifier = status.icon ? ( + + ) : ( + + ); + return ( + + + {signifier} + + + ); + }, + }; + return [statusColumn, ...columns]; + }, + }), + [getStatus], + ); +} diff --git a/packages/core/src/Table/useTableRowStatus.doc.mjs b/packages/core/src/Table/useTableRowStatus.doc.mjs new file mode 100644 index 000000000000..b3e051ecaaf5 --- /dev/null +++ b/packages/core/src/Table/useTableRowStatus.doc.mjs @@ -0,0 +1,30 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('../docs-types').ComponentDoc} */ + +export const docs = { + name: 'useTableRowStatus', + subComponentOf: 'Table', + displayName: 'useTableRowStatus', + description: + 'Hook that returns a TablePlugin which prepends a narrow column signaling per-row status: a colored status dot by default, or an icon when provided (shape + color is more accessible than color alone). getStatus maps a row to a semantic color (mapped to a theme token) or raw CSS color, an optional icon, and a required accessible label (shown in a tooltip on hover and announced to assistive technology, so status is never color-only); return null for no indicator. Memoize getStatus with useCallback for a stable plugin identity.', + props: [ + { + name: 'getStatus', + type: "(item: T) => { color: 'accent' | 'success' | 'error' | 'warning' | 'red' | 'orange' | 'green' | 'yellow' | 'blue' | 'gray' | string; icon?: IconName; label: string } | null", + description: + 'Derive the status indicator for a row: a semantic color (accent, success, error, warning, red, orange, green, yellow, blue, gray — mapped to a theme token) or a raw CSS color as an escape hatch; an optional icon to signal status by shape instead of the dot (recommended when multiple statuses coexist; valid names: close, chevronDown, chevronLeft, chevronRight, check, success, error, warning, info, calendar, clock, externalLink, menu, moreHorizontal, search, arrowUp, arrowDown, arrowsUpDown, funnel, eyeSlash, viewColumns, copy, checkDouble, wrench, stop, microphone); and a required accessible label (announced via role="img" and shown in a tooltip on hover, so a status is never conveyed by color alone). Return null for rows with no status. Memoize with useCallback for a stable plugin identity.', + required: true, + }, + ], +}; + +/** @type {import('../docs-types').TranslationDoc} */ +export const docsDense = { + description: + 'Returns a TablePlugin that prepends a narrow per-row status column: a colored dot, or an icon when provided (shape + color beats color alone for a11y). getStatus maps a row to {color, icon?, label} or null. color is a semantic name (success/error/warning/etc.) mapped to a theme token, or a raw CSS color. label is required (tooltip + accessible name). Memoize getStatus with useCallback.', + propDescriptions: { + getStatus: + 'Map a row to {color, icon?, label} or null. color = semantic status name (mapped to a token) or raw CSS; icon = shape signifier (a11y); label = required accessible name (tooltip + role="img"). Memoize with useCallback.', + }, +}; diff --git a/packages/core/src/authoring/doc.ts b/packages/core/src/authoring/doc.ts index ef217628cc75..c6bde42a772e 100644 --- a/packages/core/src/authoring/doc.ts +++ b/packages/core/src/authoring/doc.ts @@ -130,9 +130,7 @@ export type AstryxGenericDocInput = AstryxBaseDocInput; * any code referring to the old name. */ export type AstryxComponentDoc = - | AstryxComponentDocInput - | AstryxFunctionDocInput - | AstryxGenericDocInput; + AstryxComponentDocInput | AstryxFunctionDocInput | AstryxGenericDocInput; /** * Author a component doc. Stamp-only: returns the def with `type: 'component'` diff --git a/packages/lab/src/RichTextEditor/RichTextEditor.test.tsx b/packages/lab/src/RichTextEditor/RichTextEditor.test.tsx index 4eec213d200b..f1b8b9207358 100644 --- a/packages/lab/src/RichTextEditor/RichTextEditor.test.tsx +++ b/packages/lab/src/RichTextEditor/RichTextEditor.test.tsx @@ -14,11 +14,7 @@ import {render, screen, waitFor} from '@testing-library/react'; import {useEffect} from 'react'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import type {LexicalEditor} from 'lexical'; -import { - $getRoot, - $createParagraphNode, - $createTextNode, -} from 'lexical'; +import {$getRoot, $createParagraphNode, $createTextNode} from 'lexical'; import {HeadingNode} from '@lexical/rich-text'; import {RichTextEditor} from './RichTextEditor'; import {RichTextView} from './RichTextView'; @@ -218,9 +214,7 @@ describe('RichTextView', () => { }); it('renders nothing (no crash) on malformed JSON with no fallback', () => { - expect(() => - render(), - ).not.toThrow(); + expect(() => render()).not.toThrow(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); }); }); diff --git a/packages/lab/src/RichTextEditor/RichTextEditor.tsx b/packages/lab/src/RichTextEditor/RichTextEditor.tsx index bbf3df34676b..f195344078f5 100644 --- a/packages/lab/src/RichTextEditor/RichTextEditor.tsx +++ b/packages/lab/src/RichTextEditor/RichTextEditor.tsx @@ -45,7 +45,10 @@ import type {SizeValue} from '@astryxdesign/core/utils'; import {useSize} from '@astryxdesign/core/SizeContext'; import {themeProps} from '@astryxdesign/core/utils'; -import {LexicalComposer, type InitialConfigType} from '@lexical/react/LexicalComposer'; +import { + LexicalComposer, + type InitialConfigType, +} from '@lexical/react/LexicalComposer'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; import {ContentEditable} from '@lexical/react/LexicalContentEditable'; @@ -143,8 +146,10 @@ export interface RichTextEditorStatus { message?: string; } -export interface RichTextEditorProps - extends Omit { +export interface RichTextEditorProps extends Omit< + BaseProps, + 'onChange' | 'defaultValue' +> { /** Label text for the editor (always rendered for accessibility). */ label: string; /** @@ -313,7 +318,6 @@ export function RichTextEditor({ .filter(Boolean) .join(' ') || undefined; - return (