Compare commits
323
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1065fb27 | ||
|
|
ed1d3a59cf | ||
|
|
62d1dd8bf3 | ||
|
|
0a17867ae7 | ||
|
|
c3818f0041 | ||
|
|
41383960f8 | ||
|
|
a023049431 | ||
|
|
646814d6df | ||
|
|
bccbf1f4c9 | ||
|
|
9030e7d3a3 | ||
|
|
1bed8e4b74 | ||
|
|
5eae6f3b3c | ||
|
|
1179fc2810 | ||
|
|
f4eaf416be | ||
|
|
ab0a4ff27d | ||
|
|
dfb507ffb3 | ||
|
|
c77f5e1cec | ||
|
|
d800228585 | ||
|
|
03efca5bda | ||
|
|
32d3cac828 | ||
|
|
b34930f8f6 | ||
|
|
2d9797856d | ||
|
|
cc464afdfb | ||
|
|
a154e6c26e | ||
|
|
a4a31032b9 | ||
|
|
645914955c | ||
|
|
01abe351e9 | ||
|
|
9f2b5bb5d9 | ||
|
|
45343760e0 | ||
|
|
01c1aa567e | ||
|
|
640dadaa75 | ||
|
|
eb1875f0bc | ||
|
|
e019ac34c8 | ||
|
|
1f90e01e6e | ||
|
|
14669bb4f3 | ||
|
|
16d804e16f | ||
|
|
89954181ec | ||
|
|
0fa3c01a16 | ||
|
|
df382ed550 | ||
|
|
33e350a66a | ||
|
|
c023a98052 | ||
|
|
d41eda7050 | ||
|
|
3da88510df | ||
|
|
b0fca36bca | ||
|
|
07ed6b2596 | ||
|
|
1c05e02d94 | ||
|
|
f5139f2c82 | ||
|
|
69db4bd62e | ||
|
|
4e0f5adb5f | ||
|
|
6afffe9f08 | ||
|
|
ca2fcb4016 | ||
|
|
88207cdd48 | ||
|
|
0f8d10eb91 | ||
|
|
7110e1a7f1 | ||
|
|
f9cf2a648a | ||
|
|
a1704877e8 | ||
|
|
88f2e456ef | ||
|
|
2a93e141aa | ||
|
|
7afd60e812 | ||
|
|
e1ea6e2977 | ||
|
|
8bc8fe85c3 | ||
|
|
c1f4f42b21 | ||
|
|
5e30d6a7d0 | ||
|
|
6b35f522b9 | ||
|
|
4ac31d750e | ||
|
|
bb28b8759a | ||
|
|
42b91f765d | ||
|
|
2f735b267b | ||
|
|
8e51b28583 | ||
|
|
b915a2206f | ||
|
|
612b3945ac | ||
|
|
baee958c68 | ||
|
|
371c23db46 | ||
|
|
fd777605e1 | ||
|
|
d1f42e0566 | ||
|
|
4df4173dfb | ||
|
|
1143da808f | ||
|
|
2cd8f8ea14 | ||
|
|
d8b9c2dcb5 | ||
|
|
ad81306625 | ||
|
|
09389993d2 | ||
|
|
157eec7a78 | ||
|
|
2428df6e78 | ||
|
|
353cadda60 | ||
|
|
6cdd39ba38 | ||
|
|
edccf06658 | ||
|
|
0bd2a6860d | ||
|
|
06433e4ac4 | ||
|
|
b807f7b5a7 | ||
|
|
d3f26ad16e | ||
|
|
1b470402c7 | ||
|
|
2e770f7e3e | ||
|
|
2d77f5457c | ||
|
|
dd8d6ecc3b | ||
|
|
95c291db64 | ||
|
|
c578bc0f92 | ||
|
|
10771f1a2b | ||
|
|
b1064d442a | ||
|
|
6dea64b84c | ||
|
|
b1db287f2a | ||
|
|
9d90418490 | ||
|
|
61a4f01321 | ||
|
|
bef67b0a24 | ||
|
|
6123a6865e | ||
|
|
18f1d08bc3 | ||
|
|
d0e0dfeb26 | ||
|
|
0c77de93b4 | ||
|
|
298e34ef2e | ||
|
|
65d63dfbe7 | ||
|
|
a077515336 | ||
|
|
24f15e8b78 | ||
|
|
43bc2330b2 | ||
|
|
9e0907ffb2 | ||
|
|
b1b84eb41c | ||
|
|
fe40cc56df | ||
|
|
1dd60ded0d | ||
|
|
25a421c727 | ||
|
|
681679483a | ||
|
|
a85cbf240d | ||
|
|
26af30cfe6 | ||
|
|
bfa72d5679 | ||
|
|
aec1dc6056 | ||
|
|
6acf78a72c | ||
|
|
f7fe3bb17b | ||
|
|
84e16ebc41 | ||
|
|
7993a303ac | ||
|
|
a99290a216 | ||
|
|
c648efc240 | ||
|
|
decbee2fd1 | ||
|
|
e62e7f5d27 | ||
|
|
b2c74a3a29 | ||
|
|
fe86570f45 | ||
|
|
bc1211ea8b | ||
|
|
d2e24eb089 | ||
|
|
e71885d51c | ||
|
|
138b5d0950 | ||
|
|
7e1eb899b3 | ||
|
|
3f6612cfe7 | ||
|
|
510b3ba777 | ||
|
|
1ff2c3339c | ||
|
|
027873459d | ||
|
|
d0bccfe37e | ||
|
|
120889060a | ||
|
|
26ec18c24b | ||
|
|
7658c5d499 | ||
|
|
3cb6ba1233 | ||
|
|
64db7da08d | ||
|
|
9f6552ea62 | ||
|
|
5bc391e74f | ||
|
|
3327ec0d37 | ||
|
|
ceb865f4f1 | ||
|
|
45309b3f72 | ||
|
|
8f3a13c854 | ||
|
|
eca4447dcf | ||
|
|
904bca21b2 | ||
|
|
b98d417185 | ||
|
|
7d737ec0a0 | ||
|
|
f5538ed621 | ||
|
|
d2d830e7e2 | ||
|
|
0618782355 | ||
|
|
9067e68f69 | ||
|
|
9d64ec60c4 | ||
|
|
fc1eea3939 | ||
|
|
4ec5dad60a | ||
|
|
a1a2947910 | ||
|
|
270040fb5f | ||
|
|
f9e285b182 | ||
|
|
3b402b4e36 | ||
|
|
fa535575c5 | ||
|
|
46ef4b775b | ||
|
|
7a36c1905f | ||
|
|
1a8c75a958 | ||
|
|
b3c93e9b18 | ||
|
|
613ff7ae5b | ||
|
|
e372ae571a | ||
|
|
354082ebc3 | ||
|
|
aa21bc4a18 | ||
|
|
1be482caab | ||
|
|
2d1cda0eab | ||
|
|
d26403fce2 | ||
|
|
f52e1d50e4 | ||
|
|
fe44bc2091 | ||
|
|
98e0813061 | ||
|
|
a9670c2a1c | ||
|
|
1a94179b5f | ||
|
|
f13e83fd74 | ||
|
|
7351ed1566 | ||
|
|
53b9947ef9 | ||
|
|
56cde5aaf8 | ||
|
|
4be4b2c346 | ||
|
|
6258495b67 | ||
|
|
631e1a2ddb | ||
|
|
2e12656060 | ||
|
|
d87b197224 | ||
|
|
64ab20cc95 | ||
|
|
46195462c3 | ||
|
|
8fb96f521f | ||
|
|
9377a69d88 | ||
|
|
9332522614 | ||
|
|
37439188ca | ||
|
|
7c5069b5b5 | ||
|
|
41b7eb8798 | ||
|
|
0be69fb234 | ||
|
|
bab11083c6 | ||
|
|
a3ddd39b7b | ||
|
|
96e082b943 | ||
|
|
2299a0726d | ||
|
|
2d0df4efc9 | ||
|
|
20783699ac | ||
|
|
d8305a5a14 | ||
|
|
98a46091b5 | ||
|
|
2c50a07ed2 | ||
|
|
9746e8e875 | ||
|
|
0ddb481159 | ||
|
|
3441b96362 | ||
|
|
84e58b1798 | ||
|
|
de4477dd9c | ||
|
|
2970d5fff3 | ||
|
|
eeef2d79df | ||
|
|
ef6de302e9 | ||
|
|
a970d04248 | ||
|
|
5429057a38 | ||
|
|
e944fd4496 | ||
|
|
861444b8a3 | ||
|
|
ba957e861e | ||
|
|
29febb2e0c | ||
|
|
cd0dffa87d | ||
|
|
f57bd05a03 | ||
|
|
2bb52487eb | ||
|
|
990a8128a7 | ||
|
|
7a1b4e5b33 | ||
|
|
aded2daab9 | ||
|
|
6bb3ab5fe7 | ||
|
|
4aa0973352 | ||
|
|
e616ed276e | ||
|
|
74490964ee | ||
|
|
7a3e55b150 | ||
|
|
fb24cc7ec3 | ||
|
|
63316111cb | ||
|
|
0ed8351828 | ||
|
|
890d6106b3 | ||
|
|
de99a2c2e0 | ||
|
|
442c058d0c | ||
|
|
a08dfa5322 | ||
|
|
712a6d3d8c | ||
|
|
9dec811069 | ||
|
|
847b7010d1 | ||
|
|
1dbe16dc93 | ||
|
|
f32b33e3b6 | ||
|
|
768074ac0a | ||
|
|
c7e40154ca | ||
|
|
680ea73975 | ||
|
|
953b1cd692 | ||
|
|
87a00de5f3 | ||
|
|
d069f5ddd7 | ||
|
|
abebbcf8af | ||
|
|
5720f93db1 | ||
|
|
7f5e1bc3cf | ||
|
|
97d60d0ba8 | ||
|
|
e7e7b58160 | ||
|
|
e09a35b4bd | ||
|
|
5c8f3ede54 | ||
|
|
d19595c7ba | ||
|
|
b7796b103a | ||
|
|
ec63090006 | ||
|
|
224af8fd96 | ||
|
|
68cc75d0b0 | ||
|
|
43652c14af | ||
|
|
e40d8319a6 | ||
|
|
2f075df42c | ||
|
|
281615a4b0 | ||
|
|
3ae5d7cf97 | ||
|
|
b5029889a5 | ||
|
|
e9db4ca24d | ||
|
|
5318320c70 | ||
|
|
b3ed9689fa | ||
|
|
e5de7b54a5 | ||
|
|
3252b1b20e | ||
|
|
7601477f7d | ||
|
|
847b6dbe59 | ||
|
|
04be24ddd8 | ||
|
|
785e8a65bd | ||
|
|
a2698fb51a | ||
|
|
419614d9d1 | ||
|
|
70777e4a45 | ||
|
|
bd2f6ac5e3 | ||
|
|
b457ad1d54 | ||
|
|
323f57b32b | ||
|
|
028c2a6d64 | ||
|
|
2f0f82b29f | ||
|
|
50097ec4b4 | ||
|
|
f026415ba6 | ||
|
|
2cbc3e43e1 | ||
|
|
55fed2177a | ||
|
|
8c609edd32 | ||
|
|
e898929193 | ||
|
|
18a1c40118 | ||
|
|
a20f143acb | ||
|
|
ac248f2bb0 | ||
|
|
0904a4efaa | ||
|
|
5b11b937bb | ||
|
|
f199385204 | ||
|
|
c0c2fa4595 | ||
|
|
cfdcdd00ad | ||
|
|
03d5cb6aa6 | ||
|
|
701acd828c | ||
|
|
b28b79c370 | ||
|
|
fdd0c9f847 | ||
|
|
7ad336b4dd | ||
|
|
d70e89230b | ||
|
|
7e6d8c3bc8 | ||
|
|
92c0920c9b | ||
|
|
a8d8ac386f | ||
|
|
2c8841cc5f | ||
|
|
b344d2a70a | ||
|
|
e83f0366ef | ||
|
|
d9e8f5be82 | ||
|
|
bd0c1317ff | ||
|
|
97801287f9 | ||
|
|
d77638131b | ||
|
|
609224591c | ||
|
|
6074d19c43 | ||
|
|
7301849a7f |
No files matched your search
@@ -13,6 +13,30 @@
|
||||
],
|
||||
"port": 3520
|
||||
},
|
||||
{
|
||||
"name": "sendline",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": ["run", "--cwd", "D:/Company/sendline", "dev", "--port=3610"],
|
||||
"port": 3610
|
||||
},
|
||||
{
|
||||
"name": "site",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": ["run", "--cwd", "D:/Company/site", "dev", "--port=3620"],
|
||||
"port": 3620
|
||||
},
|
||||
{
|
||||
"name": "wrnexusjs-docs",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": ["run", "--cwd", "D:/Company/wrnexusjs", "dev", "--port=3630"],
|
||||
"port": 3630
|
||||
},
|
||||
{
|
||||
"name": "showcase-deploy",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": ["run", "--cwd", "D:/Company/component-showcase", "dev", "--port=3640"],
|
||||
"port": 3640
|
||||
},
|
||||
{
|
||||
"name": "component-showcase",
|
||||
"runtimeExecutable": "bun",
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
bun-version: 1.4.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
@@ -25,3 +25,9 @@ tsconfig.focus.json
|
||||
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
||||
# which requires the scaffold to live inside the repo tree).
|
||||
**/test/.tmp-*/
|
||||
|
||||
# Subagent-driven-development scratch (ledger, briefs, review packages)
|
||||
.superpowers/
|
||||
|
||||
# Scratch modules written by tests that must resolve this workspace.
|
||||
.tmp-tests/
|
||||
@@ -13,6 +13,7 @@ bun.lockb
|
||||
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
||||
**/*.gen.ts
|
||||
**/*.generated.d.ts
|
||||
**/*.generated.api-checks.ts
|
||||
|
||||
# Bundled .wrn compiler for the VS Code extension (generated)
|
||||
editors/vscode/src/compiler.cjs
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added framework-owned authentication and authorization integration: lazy auth stores,
|
||||
configured OAuth routes with cookie-bound transactions, production configuration validation,
|
||||
default lifecycle roles, typed request users, declarative API permissions, page guards, and
|
||||
consistent unauthorized/forbidden responses.
|
||||
- Added reusable application primitives for typed route params, bounded cursor pagination,
|
||||
owned-resource authorization, atomic database state transitions, queue batching and explicit
|
||||
worker lifecycles.
|
||||
- Expanded `@wrnexus/test` with official typed contexts, Bun-compatible fetch mocking and a
|
||||
full-stack harness alias; made generated files deterministic and introduced `wrnexus check` as
|
||||
the canonical build-before-check command for generated applications.
|
||||
|
||||
- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts`
|
||||
and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or
|
||||
`"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
|
||||
`"yes"`, `"1"`, `"TRUE"`, `"treu"`) passed validation as a silent, wrong `false`. Now
|
||||
recognised true strings (`"true"`, `"on"`, `"1"`, `"yes"`, case-insensitive and trimmed) and
|
||||
false strings (`"false"`, `"off"`, `"0"`, `"no"`) coerce as expected, numeric `1`/`0` coerce
|
||||
(for JSON payloads), and absent/empty input (`undefined`/`null`/`""`) still coerces to
|
||||
`false` exactly as before (unchanged HTML-checkbox semantics). **Behavior change for
|
||||
downstream apps:** any other value — an unrecognised string, an object, an array — is now a
|
||||
type error (`desc.typeMessage` or "Must be true or false") instead of a silent `false`. A
|
||||
required boolean field given `false` still errors, as before (checkbox-required semantics
|
||||
are unchanged). A repo-wide search of `packages/`, `examples/`, and `services/` found no
|
||||
existing `v.boolean()` usage that feeds an unrecognised value, so no call sites are expected
|
||||
to start failing.
|
||||
|
||||
- Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router
|
||||
(which calls handlers as `handler(ctx)`, with no second argument) actually receive their
|
||||
request input: it now parses query parameters for GET/HEAD and the JSON body otherwise
|
||||
when no input is passed explicitly. Previously such endpoints silently validated
|
||||
`undefined`, so an `input` schema with only optional fields passed vacuously regardless of
|
||||
what was sent. **Behavior change for downstream apps:** a request that previously passed
|
||||
vacuous validation on a `defineEndpoint` route can now legitimately fail (400
|
||||
`VALIDATION_ERROR`) if it does not actually satisfy the schema. Explicitly passing a second
|
||||
argument (e.g. from a unit test or an internal caller) is unaffected and still takes
|
||||
priority over reading the request.
|
||||
|
||||
## 0.8.8
|
||||
|
||||
- Added the framework request context to `.wrn` language-server type environments.
|
||||
|
||||
+2
-4
@@ -948,7 +948,7 @@ security: {
|
||||
```
|
||||
|
||||
Defaults include a strict CSP (`default-src 'self'`, `script-src 'self'`, `style-src 'self'
|
||||
'unsafe-inline'`, `frame-ancestors 'none'`, …), `X-Frame-Options: DENY`, restrictive
|
||||
'unsafe-inline'`, `frame-ancestors 'self'`, …), `X-Frame-Options: SAMEORIGIN`, restrictive
|
||||
Permissions‑Policy, COOP `same-origin`, and Referrer‑Policy. Set any directive/section to `false`
|
||||
to remove it, or `security.headers = false` to disable all framework headers. WebSocket upgrades are
|
||||
guarded against cross‑site hijacking (same‑origin + configured CORS origins + non‑browser clients).
|
||||
@@ -1288,8 +1288,6 @@ wrnexus help | --help | -h
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
head: [ '<link rel="stylesheet" href="…">' ], // string | string[] → appended to every <head>
|
||||
|
||||
seo: { // SeoConfig (global defaults, merged per page)
|
||||
@@ -1344,7 +1342,7 @@ export default config;
|
||||
|
||||
**CSP defaults:** `default-src 'self'`; `script-src 'self'`; `style-src 'self' 'unsafe-inline'`;
|
||||
`img-src 'self' data: blob:`; `font-src 'self' data:`; `connect-src 'self' ws: wss:`;
|
||||
`object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'none'`; `form-action 'self'`. Set a
|
||||
`object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'self'`; `form-action 'self'`. Set a
|
||||
directive to `false`/`null` to remove it.
|
||||
|
||||
## 33. Directive & attribute cheat sheet
|
||||
|
||||
@@ -166,6 +166,10 @@ Supported view features include:
|
||||
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
|
||||
- comments and scoped styles
|
||||
|
||||
`{#if}` and `{#each}` are rendered on the server for the initial response and
|
||||
remain reactive after hydration. Browser state changes switch conditional
|
||||
branches and rerender loop rows, including the `{:empty}` branch.
|
||||
|
||||
Output is escaped by default. Explicit raw HTML APIs must be treated as security
|
||||
boundaries.
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
# Application productivity kit
|
||||
|
||||
WrNexus applications should declare infrastructure once and keep only domain decisions locally.
|
||||
|
||||
## Included foundations
|
||||
|
||||
1. HTTP routes: `defineApiRoute`, `authorized`, `requireUser`, `requirePermission`, `requireParam`, `requireJson`, and `json`.
|
||||
2. CRUD resources: `defineResource` generates owned list/create/get/update/delete handlers.
|
||||
3. Seed data: `addSeedData`, `upsertSeedData`, `seedIfMissing`, `defineSeed`, `removeSeedData`, and `runSeedQuery`.
|
||||
4. Transactional delivery: `withOutbox`, `drainOutbox`, and `installOutboxSchema`.
|
||||
5. Job state transitions: `defineJobStateMachine`.
|
||||
6. SQLite/PostgreSQL/MySQL queue tables: configured storage plus `databaseQueueStore`.
|
||||
7. Queue operations: success/failed hooks, dashboard handler and queue CLI.
|
||||
8. Queue testing: `testQueue` with deterministic `runNext`/`runAll`.
|
||||
9. Workflows and compensation: durable workflows and `defineSaga`.
|
||||
10. Mail: `defineMail`, `defineMailTemplate`, sandbox allowlists and `queuedMail`.
|
||||
11. Encrypted settings: `defineSecretResource`.
|
||||
12. Ledgers: `defineLedger` with atomic debit and audit entries.
|
||||
13. Forms: `useForm` for values, errors, dirty/submitting state, validation and submission.
|
||||
14. Typed HTTP calls: `createApiClient`; generated OpenAPI/SDK clients remain available through `wrnexus api generate`.
|
||||
15. Full-stack testing: `createTestApp`, database rollback/factories, and queue test controls.
|
||||
16. Package migrations: queue/auth/authz packages contribute migrations through the plugin lifecycle.
|
||||
17. Auth/OAuth: auth and authz plugins own routes, middleware, roles and provider lifecycle.
|
||||
18. Code generation: `wrnexus generate resource <name>`.
|
||||
19. Incremental features: `wrnexus add queue|mail|seed|testing|resource`.
|
||||
20. Starter presets: `wrnexus create app --preset=saas|email` and `--features=...`.
|
||||
|
||||
## Recommended new-project flow
|
||||
|
||||
```sh
|
||||
wrnexus create acme --preset=saas
|
||||
cd acme && bun install
|
||||
wrnexus generate resource projects
|
||||
wrnexus add mail
|
||||
wrnexus db migrate
|
||||
wrnexus db seed
|
||||
wrnexus dev
|
||||
```
|
||||
|
||||
Use generated resources for ordinary owned CRUD. Use the outbox for business operations that
|
||||
change data and enqueue work together. Use sagas only when a workflow crosses systems that cannot
|
||||
share a transaction. Application code should retain product-specific policy; framework code owns
|
||||
storage, validation, lifecycle, retries, diagnostics and scaffolding.
|
||||
@@ -0,0 +1,332 @@
|
||||
# Universal file viewer design (`@wrnexus/viewer`)
|
||||
|
||||
Date: 2026-09-08
|
||||
Status: implemented — build order steps 1-5 complete.
|
||||
Follow-ups: [2026-09-09-file-viewer-follow-ups.md](2026-09-09-file-viewer-follow-ups.md)
|
||||
Depends on: `@wrnexus/ui` (tokens, `.wrn` components), `@wrnexus/plugin` (`routeEntries`),
|
||||
`@wrnexus/cache`, `@wrnexus/block-editor` (read-only CodeMirror), `@wrnexus/uploader` (optional)
|
||||
|
||||
## Problem
|
||||
|
||||
Every app in the workspace that stores a file eventually has to show it. Today each one
|
||||
solves this alone: an `<img>` here, an `<iframe src=…>` there, a download link for
|
||||
everything else. There is no shared answer for "the user uploaded a `.docx`, show it to
|
||||
them", no consistent chrome (zoom, paging, download, fullscreen), and no consistent theming —
|
||||
a preview pane looks nothing like the app around it.
|
||||
|
||||
`@wrnexus/uploader` already owns getting bytes _in_ and serving them back out, including the
|
||||
extension↔MIME table in `src/mime.ts`. `@wrnexus/ui` already owns the token system that makes
|
||||
components follow an app's accent and dark mode. `@wrnexus/block-editor` already wraps
|
||||
CodeMirror read-only in `src/source-editor.ts`. What is missing is the layer that looks at an
|
||||
arbitrary file and decides how to render it.
|
||||
|
||||
## Scope
|
||||
|
||||
One package that any app drops in as a single component:
|
||||
|
||||
```wrn
|
||||
<Viewer src={file.url} name={file.name} />
|
||||
```
|
||||
|
||||
…and gets the best available rendering of that file, themed to the app, with a graceful
|
||||
fallback when the format cannot be rendered at all.
|
||||
|
||||
It is **not** an editor. Everything is read-only; editing stays with `@wrnexus/block-editor`
|
||||
and `WysiwygEditor.wrn`. It is also not a file _manager_ — no listing, no tree, no rename.
|
||||
It renders one file at a time (with an optional filmstrip for multi-file callers).
|
||||
|
||||
## Approach
|
||||
|
||||
Three tiers, decided by what the platform can actually do, not by what we would like:
|
||||
|
||||
| Tier | Rendered by | Cost |
|
||||
| ---- | ------------------------------ | ------------------------------------ |
|
||||
| 1 | The browser, natively | Zero new dependencies |
|
||||
| 2 | A JS parser in the client | Optional peer dep, lazily imported |
|
||||
| 3 | A server-side binary converter | Opt-in plugin + a binary on the host |
|
||||
|
||||
The tier boundary is a hard fact about the platform: no amount of JavaScript renders a `.psd`
|
||||
faithfully, and no server round-trip is needed to show a `.png`. Pretending otherwise is how
|
||||
viewer libraries end up shipping 8 MB to display a JPEG.
|
||||
|
||||
Everything degrades down a fixed ladder — **full render → thumbnail → metadata card →
|
||||
download card** — so an unknown extension, a missing optional dep, and an absent converter
|
||||
binary all land somewhere useful instead of erroring.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- **One monolithic `Viewer.wrn` with a big `if/else`.** Every format's dependency ends up in
|
||||
every app's bundle, and apps cannot add their own formats.
|
||||
- **Server-render everything to PDF/PNG.** Uniform output, but it makes a hard host-binary
|
||||
dependency mandatory for viewing a text file, loses text selection and copy, and puts a
|
||||
conversion hop in front of the 60% of files the browser renders for free.
|
||||
- **Wrap an existing all-in-one viewer library.** They bundle their own design system, fight
|
||||
our tokens, and none of them cover Tier 3 anyway.
|
||||
|
||||
## Module layout
|
||||
|
||||
```
|
||||
@wrnexus/viewer
|
||||
src/
|
||||
kinds.ts ViewerKind union; ext/MIME -> kind table (extends uploader's)
|
||||
resolve.ts ViewerSource -> ViewerPlan (kind, renderer, fallback chain)
|
||||
sniff.ts magic-byte detection; extensions are never trusted alone
|
||||
registry.ts renderer registry; register/override per kind or per ext
|
||||
parse/ csv, json, xml, sql, archive listing, sanitize-svg
|
||||
convert/ ViewerConverter contract + the shipped local-binary driver
|
||||
plugin.ts viewerPlugin(): componentDirs + /_viewer/* routeEntries
|
||||
uploader.ts separate export: storage key -> signed URL + metadata
|
||||
index.ts
|
||||
components/
|
||||
Viewer.wrn the one component apps use
|
||||
ViewerToolbar.wrn zoom, page, rotate, download, fullscreen
|
||||
ViewerShell.wrn chrome, loading, error, and fallback states
|
||||
ImageViewer.wrn VectorViewer.wrn PdfViewer.wrn CodeViewer.wrn
|
||||
SheetViewer.wrn DocViewer.wrn MediaViewer.wrn ArchiveViewer.wrn
|
||||
DownloadCard.wrn
|
||||
viewer.css consumes the same CSS variables as ui.css
|
||||
```
|
||||
|
||||
## Core contract
|
||||
|
||||
```ts
|
||||
export type ViewerKind =
|
||||
| "image"
|
||||
| "vector"
|
||||
| "pdf"
|
||||
| "code"
|
||||
| "text"
|
||||
| "markup"
|
||||
| "sheet"
|
||||
| "doc"
|
||||
| "audio"
|
||||
| "video"
|
||||
| "archive"
|
||||
| "binary";
|
||||
|
||||
export interface ViewerSource {
|
||||
src?: string; // URL, blob:, or data:
|
||||
blob?: Blob | File; // client-side preview before upload
|
||||
key?: string; // @wrnexus/uploader storage key
|
||||
name: string;
|
||||
mime?: string; // sniffed when absent
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface Renderer {
|
||||
kind: ViewerKind;
|
||||
exts: string[];
|
||||
tier: 1 | 2 | 3;
|
||||
component: string; // .wrn component name
|
||||
load?: () => Promise<unknown>; // lazy optional dep; missing -> degrade
|
||||
maxInlineBytes?: number;
|
||||
}
|
||||
|
||||
export interface ViewerPlan {
|
||||
kind: ViewerKind;
|
||||
renderer: Renderer;
|
||||
fallback: Renderer[]; // the degradation ladder, pre-computed
|
||||
needsConvert: boolean;
|
||||
sandboxed: boolean;
|
||||
}
|
||||
|
||||
export function resolve(source: ViewerSource): Promise<ViewerPlan>;
|
||||
export function registerRenderer(renderer: Renderer): void;
|
||||
```
|
||||
|
||||
`resolve()` runs ext → declared MIME → magic-byte sniff, then picks the highest-tier renderer
|
||||
whose dependency is actually available. Apps override anything:
|
||||
|
||||
```ts
|
||||
registerRenderer({ kind: "binary", exts: [".dwg"], tier: 2, component: "CadViewer" });
|
||||
```
|
||||
|
||||
## Format matrix
|
||||
|
||||
**Tier 1 — browser-native, zero dependencies.**
|
||||
|
||||
| Group | Extensions | Renderer |
|
||||
| --------- | -------------------------------------------------------- | ------------------------------ |
|
||||
| Images | `.jpg .jpeg .png .gif .webp .bmp .ico .avif` | `ImageViewer` (zoom/pan) |
|
||||
| Video | `.mp4 .webm .mov` | `MediaViewer` (`<video>`) |
|
||||
| Audio | `.mp3 .wav .aac .flac .ogg` | `MediaViewer` + waveform bar |
|
||||
| Documents | `.pdf` | `PdfViewer` (native `<embed>`) |
|
||||
| Text | `.txt` | `CodeViewer` (plain mode) |
|
||||
| Code | `.js .py .java .cpp .cs .css .html .htm .json .xml .sql` | `CodeViewer` (CodeMirror) |
|
||||
|
||||
`.mov` and `.flac` are codec-dependent in practice; when playback fails the ladder drops to
|
||||
the Tier 3 poster-frame/transcode path, then to the download card.
|
||||
|
||||
**Tier 2 — pure JS in the client, lazily loaded.**
|
||||
|
||||
| Extensions | Parser | Notes |
|
||||
| ------------------ | -------------------- | ------------------------------------------- |
|
||||
| `.csv` | in-package | Virtualized table; handles quoted/multiline |
|
||||
| `.json` | in-package | Collapsible tree + raw toggle |
|
||||
| `.xml` | `DOMParser` | Tree view + raw toggle |
|
||||
| `.svg` | in-package sanitizer | Sandboxed; see Security |
|
||||
| `.xls .xlsx` | SheetJS | Sheet tabs, formulas shown as values |
|
||||
| `.docx` | mammoth | Semantic HTML, **not** faithful layout |
|
||||
| `.zip` | fflate | Entry listing, drill-in re-enters `resolve` |
|
||||
| `.pdf` (rich mode) | pdf.js | Opt-in; page API, text layer, theming |
|
||||
|
||||
All declared as optional `peerDependencies` and dynamically imported on first use. An app that
|
||||
never views a spreadsheet neither installs nor ships SheetJS.
|
||||
|
||||
**Tier 3 — requires a server-side converter.**
|
||||
|
||||
| Extensions | Converted to | Binary |
|
||||
| ------------------ | ------------------ | ---------- |
|
||||
| `.doc .odt .rtf` | PDF | `soffice` |
|
||||
| `.docx` (fidelity) | PDF | `soffice` |
|
||||
| `.ppt .pptx` | PDF | `soffice` |
|
||||
| `.psd .ai .tiff` | PNG | `magick` |
|
||||
| `.avi .mkv` | poster PNG, or MP4 | `ffmpeg` |
|
||||
| `.rar .7z` | entry listing | `7z` |
|
||||
| `.tar .gz` | entry listing | in-package |
|
||||
|
||||
## Converter contract
|
||||
|
||||
```ts
|
||||
export interface ViewerConverter {
|
||||
name: string;
|
||||
supports(kind: ViewerKind, ext: string): boolean;
|
||||
toPreview(
|
||||
input: ReadableStream,
|
||||
meta: ConvertMeta,
|
||||
): Promise<{ mime: string; stream: ReadableStream }>;
|
||||
toThumb?(input: ReadableStream, meta: ConvertMeta): Promise<Uint8Array>;
|
||||
toText?(input: ReadableStream, meta: ConvertMeta): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
`viewerPlugin()` contributes three `routeEntries`:
|
||||
|
||||
- `GET /_viewer/preview` — the renderable form (PDF or PNG)
|
||||
- `GET /_viewer/thumb` — a small poster image, for grids and filmstrips
|
||||
- `GET /_viewer/text` — extracted plain text, for search and indexing
|
||||
|
||||
Output is content-addressed by input hash + converter version and cached through
|
||||
`@wrnexus/cache`, so a 40 MB `.doc` converts once. The shipped `localBinaryConverter` probes
|
||||
for `soffice`/`magick`/`ffmpeg`/`7z` once at boot; **an absent binary is not an error** — the
|
||||
format simply falls to the download card. Apps that would rather call Gotenberg or an internal
|
||||
service implement `ViewerConverter` themselves and pass it to the plugin.
|
||||
|
||||
The converter is opt-in. `viewerPlugin()` with no options mounts no routes and shells out to
|
||||
nothing.
|
||||
|
||||
## Security
|
||||
|
||||
Uploaded files are hostile input, and this package's whole job is to open them.
|
||||
|
||||
- **Never trust the extension.** `sniff.ts` reads magic bytes before choosing a renderer, so a
|
||||
`.png` that is really an HTML document does not reach an HTML path.
|
||||
- **Sandbox all markup.** `.html`, `.htm`, and `.svg` render inside an iframe with `sandbox`
|
||||
(no `allow-scripts`, no `allow-same-origin`) under a strict CSP. SVG is _additionally_
|
||||
stripped of `<script>`, `<foreignObject>`, external references, and every `on*` attribute —
|
||||
sandbox and sanitizer, not either alone. Nothing from a file ever reaches `innerHTML`.
|
||||
- **Cap what is fetched.** `maxInlineBytes` per renderer, checked against a `HEAD` before the
|
||||
body is pulled; over the cap the ladder drops straight to the download card rather than
|
||||
pulling a 400 MB `.mkv` into a tab.
|
||||
- **Contain the converters.** `soffice`, `magick`, and `ffmpeg` are large C/C++ parsers with a
|
||||
long CVE history. Every invocation gets a hard wall-clock timeout, memory and output-size
|
||||
caps, no network, and a per-job temp directory as its only writable path. A converter that
|
||||
exceeds any limit is killed and reported as an unrenderable file.
|
||||
- **Signed, short-lived URLs.** The uploader integration mints per-view URLs; converted output
|
||||
inherits the same access check as its input, never a public cache path.
|
||||
|
||||
## Theming
|
||||
|
||||
`viewer.css` defines no colors of its own. It consumes the same CSS variables `ui.css` does,
|
||||
so accent, radius, spacing, and dark mode follow the host app with no configuration —
|
||||
including the CodeMirror theme, which is derived from the tokens rather than shipping a
|
||||
hard-coded editor palette. `<Viewer theme="auto" />` follows the app; `"light"` / `"dark"`
|
||||
pin it for cases like a print preview.
|
||||
|
||||
## Uploader integration
|
||||
|
||||
Core is source-agnostic: `src` or `blob` is enough, and the package has no hard dependency on
|
||||
`@wrnexus/uploader`. The `key` path lives behind a separate export (`@wrnexus/viewer/uploader`)
|
||||
that resolves a storage key to a signed URL and reads MIME and size from the upload record,
|
||||
so uploader-backed apps write `<Viewer key={f.key} />` and everyone else is unaffected. The
|
||||
ext↔MIME table extends `uploader/src/mime.ts` rather than forking it.
|
||||
|
||||
## Testing
|
||||
|
||||
- `resolve()` table tests over every extension in the matrix, including conflicting
|
||||
extension/MIME/magic-byte combinations.
|
||||
- Sanitizer tests: a corpus of hostile SVG and HTML, asserting no script survives.
|
||||
- Degradation tests: optional dep missing, converter binary missing, file over the byte cap —
|
||||
each must land on the expected rung of the ladder, never throw.
|
||||
- Converter tests run only when the binaries are present, and are skipped otherwise, so CI
|
||||
without LibreOffice stays green.
|
||||
- A `.wrn` render test per component against the example app.
|
||||
|
||||
The remaining presentation and verification follow-ups were completed on 2026-09-09; see
|
||||
[the follow-up plan](2026-09-09-file-viewer-follow-ups.md). The checkpoint below records the original implementation.
|
||||
|
||||
## Build order
|
||||
|
||||
Implementation checkpoint (2026-09-08): steps 1, 2 and 4 are done; step 3 was skipped for now
|
||||
and step 5 remains. `packages/viewer` has classification, signature sniffing, a renderer
|
||||
registry, bounded source loading, native image/PDF/media previews, read-only CodeMirror, the
|
||||
fallback ladder, plugin assets and lifecycle cleanup; the in-package Tier 2 parsers (delimited
|
||||
tables with windowed rendering, lazy JSON and XML trees, the SVG sanitiser behind a sandboxed
|
||||
frame, tar/gzip listings); and the Tier 3 converter contract with its opt-in routes, response
|
||||
cache and contained local-binary driver. `examples/basic-app` has a `/file-viewer` demo
|
||||
covering each group, with conversion enabled.
|
||||
|
||||
Five deviations from this document, all deliberate:
|
||||
|
||||
- Renderers are imperative browser adapters sharing one `ViewerMountContext`, not the
|
||||
separate `.wrn` components listed under Module layout. `Viewer.wrn` is the only component.
|
||||
`component` is a display label; the runtime does not switch on it, and a renderer with no
|
||||
`mount` is never selected.
|
||||
- Conversion routes are `/api/viewer/*`, not `/_viewer/*`: package API routes are served
|
||||
under the `/api` prefix, as `@wrnexus/graphql` already does.
|
||||
- Image, media and PDF renderers are `streaming`: for an http(s) source the element is
|
||||
pointed at the original URL instead of a `blob:` copy. That lets the browser stream the
|
||||
file, and it keeps the request under the app's own `default-src`/`media-src`/`frame-src`
|
||||
policy — `blob:` URLs are blocked by the framework's default CSP. Local `File`/`Blob`
|
||||
input still renders from an object URL served under its sniffed type.
|
||||
- The PDF renderer uses an `iframe`, not `<object>`, because `object-src 'none'` is a common
|
||||
default while frames fall back to `default-src`. Embedding a same-origin PDF still needs
|
||||
the app to allow self-framing; the framework defaults (`frame-ancestors 'none'` plus
|
||||
`X-Frame-Options: DENY`, `packages/core/src/headers.ts`) block an app from framing even its
|
||||
own assets, so `examples/basic-app` opts into `frame-ancestors 'self'` / `SAMEORIGIN`.
|
||||
- `.xlsx` and `.zip` prefer their Tier 2 parser over the converter, since neither needs a
|
||||
server round-trip; `.docx` prefers the converter, because mammoth's HTML is not a faithful
|
||||
page layout. `.psd`, `.tiff` and other design formats have signatures so they reach the
|
||||
converter or the download card rather than being mistaken for text.
|
||||
- pdf.js is served from its own published build rather than bundled — bundling its ESM entry
|
||||
breaks the worker handshake — and each page render is bounded by a timeout, because pdf.js
|
||||
rasterises from `requestAnimationFrame`, which never fires while a document is hidden.
|
||||
|
||||
A sixth deviation: there is no default access check for a private object. The plan said the
|
||||
uploader integration "mints per-view URLs"; in practice a private store is refused unless the
|
||||
app supplies both a signing secret and an `authorize` hook, because no default answer to "may
|
||||
this viewer see this object" is safe.
|
||||
|
||||
See `packages/viewer/README.md` for current limits, the renderer extension API, the converter
|
||||
contract, and the uploader options.
|
||||
|
||||
1. ~~**Core + Tier 1.**~~ Done. `kinds`, `sniff`, `resolve`, `registry`, the toolbar and
|
||||
shell chrome, image, media, native PDF, `CodeViewer` over the existing read-only
|
||||
CodeMirror, and the download card.
|
||||
2. ~~**Tier 2 in-package parsers.**~~ Done. csv/tsv, json, xml, svg sanitiser, `tar`/`gz`
|
||||
listing — still zero runtime dependencies.
|
||||
3. ~~**Tier 2 optional deps.**~~ Done. SheetJS, mammoth, fflate and opt-in pdf.js, each an
|
||||
optional peer dependency served as its own runtime asset and fetched on first use.
|
||||
4. ~~**Tier 3.**~~ Done. `ViewerConverter`, `/api/viewer/{capabilities,preview,thumb,text}`,
|
||||
a bounded response cache, `localBinaryConverter`, and containment of the child processes.
|
||||
5. ~~**Uploader integration + docs**~~ Done. `@wrnexus/viewer/uploader`, `<Viewer key="…">`,
|
||||
signed short-lived links for private stores, and `/file-viewer` in `examples/basic-app`
|
||||
exercising every group including public and private storage keys.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Editing or annotation of any kind.
|
||||
- File management (listing, rename, delete, move).
|
||||
- OCR over scanned PDFs and images.
|
||||
- Collaborative or presence features on a preview.
|
||||
- `.exe`/`.dll` and other executables — metadata card only, never executed or introspected.
|
||||
@@ -0,0 +1,142 @@
|
||||
# File viewer follow-ups (`@wrnexus/viewer`)
|
||||
|
||||
Date: 2026-09-09
|
||||
Status: implemented — accepted limits retained
|
||||
Follows: [file viewer design](2026-09-08-file-viewer-design.md), whose build order is complete
|
||||
|
||||
Build order steps 1-5 are implemented and the package renders every group in the format
|
||||
matrix. What remains falls into four kinds: defects found after the fact, work the design
|
||||
described but that was never built, one verification gap, and limits that were accepted
|
||||
deliberately and should stay accepted until something forces the issue.
|
||||
|
||||
Nothing here blocks using the package. The first section is the only part with a
|
||||
correctness argument behind it.
|
||||
|
||||
## Implementation result (2026-09-09)
|
||||
|
||||
- Stable conversion identities and authorization before cache hits; source streams released on hits.
|
||||
- Exported `firstOf` / `sameOriginSource`; demo uses the composite with signed uploads.
|
||||
- Renderer and chrome `.wrn` presentation components integrated through preserved DOM hooks.
|
||||
- Sanitized HTML preview by default, with Source toggle and strict sandbox/CSP.
|
||||
- Multi-file filmstrip consuming thumbnails, plus on-demand audio waveform and seek slider.
|
||||
- Extracted-text UI and bounded public helper consuming the text route.
|
||||
- Explicit host base URI for conversion/optional-module helpers, with regression tests.
|
||||
- Suppressed plugin factories are skipped before import/invocation.
|
||||
- Same-origin framing is now the framework default, explicitly approved by the user;
|
||||
explicit DENY still sets restrictive framing headers.
|
||||
- pdf.js success observed in ordinary Chrome: a completed page canvas visibly painted the
|
||||
sample document text. Waveform generation and filmstrip switching also verified there.
|
||||
|
||||
The per-process cache, streaming-driver byte-range limitation and host-binary DOCX fidelity
|
||||
remain accepted as this plan specifies. No distributed-cache or uploader-driver redesign was
|
||||
introduced. The findings below are the original rationale, retained for context.
|
||||
|
||||
## Defects worth fixing first
|
||||
|
||||
**A rotating token defeats the conversion cache.** `packages/viewer/src/convert/service.ts`
|
||||
keys cached output on `src`, and for a private uploader object `src` is
|
||||
`/api/viewer/file?token=…` — a link that rotates every `ttlMs` (5 minutes by default). Every
|
||||
view after the link is re-minted is a cache miss, so exactly the files most expensive to
|
||||
convert (a 40 MB `.docx` through LibreOffice) are the ones that never hit. The cache key must
|
||||
identify the _object_, not the URL that happens to reach it: have `resolveSource` return an
|
||||
optional stable identity (`store:key` for uploader sources, the URL for everything else) and
|
||||
hash that instead. Until then, private conversions re-run on a timer.
|
||||
|
||||
**The example app can no longer convert its own static files.**
|
||||
`examples/basic-app/wrnexus.config.ts` sets `resolveSource: uploadedConversionSource()`, which
|
||||
replaces the same-origin default outright. Uploader objects convert; the page's own
|
||||
`/viewer/sample.docx` cannot, because it has no signed link. On this machine that is invisible
|
||||
— no LibreOffice, so the request stops at 415 — and it would surface only on a host with the
|
||||
binaries installed. The example should use whichever composite lands below.
|
||||
|
||||
**There is no way to accept more than one kind of source.** `ConversionOptions.resolveSource`
|
||||
is a single function, so an app that stores some files in an uploader store and serves others
|
||||
statically has to hand-write the fan-out. Ship a composer — `firstOf(uploadedConversionSource(),
|
||||
sameOriginSource())` — and export `sameOriginSource` so the default is nameable rather than
|
||||
private to the module. Small surface, removes the trap the example fell into.
|
||||
|
||||
## Unbuilt from the design
|
||||
|
||||
**Renderer and chrome `.wrn` components.** The design's module layout lists `ViewerShell`,
|
||||
`ViewerToolbar`, `ImageViewer`, `MediaViewer`, `PdfViewer`, `CodeViewer`, `SheetViewer`,
|
||||
`DocViewer`, `ArchiveViewer` and `DownloadCard`; what exists is one `Viewer.wrn` plus
|
||||
imperative browser adapters sharing a `ViewerMountContext`. The adapters work and the seam is
|
||||
clean, so this is a presentation question, not a correctness one: components would let an app
|
||||
restyle the chrome through the normal component-override path instead of registering a
|
||||
renderer. Worth doing only if an app actually asks to restyle it.
|
||||
|
||||
**HTML still renders as inert source.** The design's security section describes `.html` in the
|
||||
same sandboxed, CSP-restricted frame as SVG. The frame exists (`renderers/frame.ts`) and SVG
|
||||
and `.docx` both use it; pointing `.html` at it is a small change. It was left out because
|
||||
inert source is strictly safer and nobody had asked for a rendered preview. Doing it means
|
||||
deciding what the default view is — preview with a source toggle, or the reverse.
|
||||
|
||||
**`/api/viewer/thumb` and `/api/viewer/text` are mounted but unused.** The routes work and the
|
||||
converter implements both. Nothing calls them because the two features that would — a
|
||||
multi-file filmstrip, and feeding extracted text to search — are not built. Either build a
|
||||
consumer or drop the routes; a live endpoint with no caller is a maintenance cost and an
|
||||
attack surface for nothing.
|
||||
|
||||
**Audio waveform and multi-file filmstrip.** Both are in the design, neither is built. The
|
||||
filmstrip is the one with a dependency: it wants `thumb`, and it wants a story for what a
|
||||
viewer showing _several_ files does to the toolbar and the disposal path.
|
||||
|
||||
## The verification gap
|
||||
|
||||
**pdf.js rasterisation has never been observed working.** The library and worker are served
|
||||
verbatim, `getDocument()` resolves and reports the page count in a real browser, and every
|
||||
step up to `page.render()` was verified. `page.render()` itself never completes in this
|
||||
environment, because pdf.js drives rasterisation from `requestAnimationFrame` and the
|
||||
preview pane reports `document.hidden === true` permanently, even when fronted. The renderer
|
||||
now bounds each page with a timeout and degrades to the native frame rather than hanging —
|
||||
that degradation _is_ verified — but the success path is unproven. It needs one run in an
|
||||
ordinary visible browser: open `/file-viewer` after `registerRenderer(richPdfRenderer())` and
|
||||
confirm a canvas paints. Until someone does that, treat rich PDF as untested.
|
||||
|
||||
## Outside the package
|
||||
|
||||
**Plugin discovery calls a suppressed manifest factory anyway.**
|
||||
`packages/plugin/src/discovery.ts:353` decides whether to keep a manifest-built plugin _after_
|
||||
constructing it, so a plugin configured explicitly in `wrnexus.config.ts` still has its
|
||||
factory invoked a second time with no options. That silently cleared the viewer's conversion
|
||||
config until `viewerPlugin` was changed to only ever arm, never disarm. Any plugin using the
|
||||
config-global idiom will hit this. Already queued as its own task.
|
||||
|
||||
**`frame-ancestors 'none'` stops an app framing its own assets.**
|
||||
`packages/core/src/headers.ts:119`, with `X-Frame-Options: DENY` alongside it. Embedding a
|
||||
same-origin PDF — or anything else in a frame — requires every app to opt out, as
|
||||
`examples/basic-app` now does. `'self'` would still block cross-origin clickjacking while
|
||||
letting an app embed its own content. This is a framework-wide security default, so it is a
|
||||
decision to take deliberately rather than a bug to fix in passing.
|
||||
|
||||
## Known limits, deliberately accepted
|
||||
|
||||
**The conversion cache is per-process.** A bounded `Map` with a TTL, so a multi-process deploy
|
||||
converts once per process and a restart loses everything. Moving it to `@wrnexus/cache` with
|
||||
the distributed invalidation layer is the obvious upgrade, and is only worth it once the key
|
||||
is fixed — a shared cache keyed on a rotating token would be shared misses.
|
||||
|
||||
**Byte ranges are served only for buffered objects.** `uploads/service.ts:135` slices a
|
||||
`Uint8Array` body for a `Range` request; a driver returning a stream answers the whole body
|
||||
instead. Seeking a large private video therefore re-reads it. Fixing this properly means a
|
||||
range-aware read on the driver contract, which belongs to `@wrnexus/uploader`, not here.
|
||||
|
||||
**Legacy `.doc` and `.ppt` remain converter-only.** The OOXML and OpenDocument families are
|
||||
ZIP containers of XML and are now read in-package, and RTF is read with no dependency at all.
|
||||
The pre-2007 OLE compound formats are a different problem — a binary record stream with no
|
||||
public schema — and reading them in a browser is not worth the size or the risk. LibreOffice
|
||||
handles them, and without it they keep the download card.
|
||||
|
||||
**`.docx` fidelity depends on a host binary.** Mammoth produces semantic HTML, not a page
|
||||
layout, so the converter's PDF is preferred and mammoth is the fallback. An app with no
|
||||
LibreOffice gets an approximation, and that is the honest ceiling of a pure-JS path.
|
||||
|
||||
**Three client helpers still read the global `document`.** `optional/load.ts:7` and
|
||||
`convert/client.ts:13,36`. `resolveKey` was changed to take the host's `baseURI` when it turned
|
||||
out to be untestable without a global; the others have the same latent problem and no test
|
||||
covering them. Consistency work, not a live bug.
|
||||
|
||||
## Out of scope, still
|
||||
|
||||
Unchanged from the original design: editing or annotation, file management, OCR,
|
||||
collaborative features, and executing anything from an uploaded file.
|
||||
@@ -0,0 +1,354 @@
|
||||
# `@wrnexus/payment` — proposal
|
||||
|
||||
**Status:** draft for review
|
||||
**Date:** 2026-08-23
|
||||
|
||||
One package that gives an application real payments: gateway configuration, initialisation,
|
||||
capture, refunds, stored methods, an admin surface, and UI components — so a team never writes
|
||||
this again.
|
||||
|
||||
## The one rule that shapes everything
|
||||
|
||||
**The package never touches a raw card number.**
|
||||
|
||||
Every supported gateway offers hosted fields or a hosted checkout that tokenises the card in
|
||||
the customer's browser, against the gateway's domain, before anything reaches your server. That
|
||||
is what keeps an application in PCI **SAQ-A** — roughly a self-assessment questionnaire —
|
||||
instead of SAQ-D, which is an audit programme with a six-figure floor.
|
||||
|
||||
So the package's UI components mount the gateway's own fields. `PayNow` renders a button that
|
||||
opens a gateway session; it never renders an `<input name="cardNumber">`, and the package
|
||||
exposes no API that would accept one. A framework that makes the cheap wrong thing easy will
|
||||
have it done by someone in a hurry.
|
||||
|
||||
## The second rule: the webhook is the truth
|
||||
|
||||
A browser redirect saying "payment succeeded" is a claim from an untrusted client. The
|
||||
authoritative event is the gateway's signed webhook.
|
||||
|
||||
So `checkPayment()` reads local state that webhooks maintain, and the local record is only ever
|
||||
advanced by a verified webhook or a server-side gateway query — never by a client callback. The
|
||||
redirect is a UX affordance: it tells the customer where to look, not the system what happened.
|
||||
|
||||
This is the single most common way a payment integration leaks money, and it is a design
|
||||
decision, not a runtime check.
|
||||
|
||||
## Money model — the same discipline as metering
|
||||
|
||||
Payments are **append-only**, exactly like `@wrnexus/metering`'s ledger, and for the same
|
||||
reason: a balance you cannot explain is a balance nobody trusts.
|
||||
|
||||
| Table | Holds |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `wrn_payment_intent` | one row per attempt: amount, currency, gateway, status, idempotency key |
|
||||
| `wrn_payment_event` | append-only; every webhook and state transition, with the raw signed payload |
|
||||
| `wrn_payment_refund` | one row per refund attempt, linked to its intent |
|
||||
| `wrn_payment_method` | stored gateway tokens — never card data, never a PAN, at most a brand and last four |
|
||||
| `wrn_payment_customer` | the mapping from your user id to each gateway's customer id |
|
||||
|
||||
A payment's current status is derived from its events, not from a mutable column that a race can
|
||||
clobber. The `status` column on the intent is a cache of that derivation, and the package ships
|
||||
a reconciliation command that recomputes it and reports disagreements.
|
||||
|
||||
**Idempotency is mandatory, not optional.** Every initialise and every refund carries a key; a
|
||||
repeat with the same key returns the original result rather than charging twice. This is where a
|
||||
double-click becomes a double-charge, and it should be impossible to opt out of.
|
||||
|
||||
## Gateways are adapters
|
||||
|
||||
```ts
|
||||
export interface PaymentGateway<TConfig = unknown> {
|
||||
readonly id: string; // "stripe" | "razorpay" | "paypal" | ...
|
||||
readonly capabilities: GatewayCapabilities;
|
||||
readonly supports: { currencies: string[] | "any"; countries: string[] | "any" };
|
||||
|
||||
createIntent(input: CreateIntentInput): Promise<GatewayIntent>;
|
||||
fetchIntent(intentRef: string): Promise<GatewayIntent>;
|
||||
cancelIntent(intentRef: string, reason: string): Promise<GatewayIntent>;
|
||||
refund(input: RefundInput): Promise<GatewayRefund>;
|
||||
verifyWebhook(req: Request, secret: string): Promise<WebhookEvent>; // signature check
|
||||
|
||||
capture?(intentRef: string, amount?: Money): Promise<GatewayIntent>; // authorize-then-capture
|
||||
listMethods?(customerRef: string): Promise<GatewayMethod[]>;
|
||||
attachMethod?(customerRef: string, token: string): Promise<GatewayMethod>;
|
||||
detachMethod?(methodRef: string): Promise<void>;
|
||||
createCustomer?(input: CustomerInput): Promise<string>;
|
||||
|
||||
readonly clientConfig: (intent: GatewayIntent) => Record<string, string>; // safe to send
|
||||
}
|
||||
```
|
||||
|
||||
`clientConfig` exists so the boundary is explicit: it returns exactly what the browser may see —
|
||||
a publishable key and a session id, never a secret. Anything not returned by it cannot reach the
|
||||
client, which is a structural guarantee rather than a code-review habit.
|
||||
|
||||
### Capabilities are declared, and the difference is refused loudly
|
||||
|
||||
This is the part that decides whether a multi-gateway abstraction holds. **Gateways are not
|
||||
interchangeable.** Some have no authorise-then-capture. Some cannot do partial refunds. Some
|
||||
have no stored-method vault. Some only settle in one currency.
|
||||
|
||||
An interface that pretends otherwise produces the worst failure mode there is: an application
|
||||
calls `capturePayment()` against a gateway that has no such concept, and gets a confusing
|
||||
gateway error at the moment money should have moved.
|
||||
|
||||
So every adapter declares what it can do, and the package refuses the rest **at call time with a
|
||||
named reason** — and, better, at **build time** where the gateway is statically known:
|
||||
|
||||
```ts
|
||||
export interface GatewayCapabilities {
|
||||
authorizeThenCapture: boolean; // Stripe yes, Razorpay effectively no
|
||||
partialRefund: boolean;
|
||||
multipleRefunds: boolean;
|
||||
storedMethods: boolean;
|
||||
customerVault: boolean;
|
||||
hostedFields: boolean; // inline hosted inputs
|
||||
hostedCheckout: boolean; // full redirect/modal
|
||||
webhookSignature: boolean; // an adapter without this is refused in production
|
||||
payouts: boolean;
|
||||
disputes: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
`capabilitiesOf("razorpay").partialRefund` is a real answer an application can branch on, and
|
||||
`RefundButton` reads it to decide whether to offer an amount field or only a full refund. A
|
||||
capability an adapter does not declare is not merely unimplemented — it is refused, with a
|
||||
message naming the gateway and the capability.
|
||||
|
||||
**`webhookSignature: false` is refused outright in production.** An adapter that cannot verify a
|
||||
webhook cannot be trusted to tell you money moved, and the whole design rests on that.
|
||||
|
||||
### The roster
|
||||
|
||||
**Tier 1 — ship first, fully covered by the contract suite:**
|
||||
|
||||
| Adapter | Notes |
|
||||
| ---------- | ---------------------------------------------------------------------------------- |
|
||||
| `sandbox` | behaves like a real gateway, signed webhooks, injectable failure modes, no network |
|
||||
| `stripe` | global; hosted fields, auth-then-capture, vault, partial and multiple refunds |
|
||||
| `razorpay` | India-first; hosted checkout, INR-centric, no true auth-then-capture |
|
||||
| `paypal` | global; hosted checkout, its own order/capture model |
|
||||
|
||||
Two real gateways prove the abstraction; one does not. Stripe and Razorpay are deliberately the
|
||||
first pair because they **differ** — capture model, currency spread, refund semantics — so the
|
||||
capability system is exercised rather than assumed.
|
||||
|
||||
**Tier 2 — same contract, added after the abstraction has survived Tier 1:**
|
||||
`adyen`, `square`, `braintree`, `mollie`, `checkout.com`, `paddle` (merchant-of-record, so tax
|
||||
and invoicing differ meaningfully).
|
||||
|
||||
**Tier 3 — regional, community-shaped:**
|
||||
`payu`, `cashfree`, `phonepe`, `paytm` (India); `paystack`, `flutterwave` (Africa);
|
||||
`midtrans`, `xendit` (South-East Asia); `authorize.net` (US legacy); `mercadopago` (LatAm).
|
||||
|
||||
### Any developer can add one
|
||||
|
||||
The roster is a starting set, not a ceiling. A custom adapter is a first-class citizen:
|
||||
|
||||
```ts
|
||||
import { defineGateway } from "@wrnexus/payment";
|
||||
|
||||
export const acme = defineGateway({
|
||||
id: "acme",
|
||||
capabilities: { partialRefund: true, storedMethods: false, webhookSignature: true /* … */ },
|
||||
supports: { currencies: ["INR", "USD"], countries: ["IN"] },
|
||||
async createIntent(input) {
|
||||
/* … */
|
||||
},
|
||||
async verifyWebhook(req, secret) {
|
||||
/* … */
|
||||
},
|
||||
// …
|
||||
});
|
||||
```
|
||||
|
||||
`defineGateway` validates the shape at registration and runs the **shared contract suite** in
|
||||
tests, so a third-party adapter is held to exactly the standard the built-in ones are. An adapter
|
||||
that passes the suite behaves identically to Stripe's from the application's point of view; one
|
||||
that does not, fails in CI rather than in production.
|
||||
|
||||
## Configuration
|
||||
|
||||
Config is **per-gateway and typed** — each adapter declares its own shape, so a missing
|
||||
`webhookSecret` is a type error, not a 3am discovery:
|
||||
|
||||
```ts
|
||||
// wrnexus.config.ts
|
||||
payment: {
|
||||
default: "stripe",
|
||||
currency: "INR",
|
||||
|
||||
gateways: {
|
||||
stripe: {
|
||||
secretKey: env("STRIPE_SECRET_KEY"),
|
||||
publishableKey: env("STRIPE_PUBLISHABLE_KEY"),
|
||||
webhookSecret: env("STRIPE_WEBHOOK_SECRET"),
|
||||
apiVersion: "2026-03-31",
|
||||
captureMethod: "automatic", // or "manual" for auth-then-capture
|
||||
},
|
||||
razorpay: {
|
||||
keyId: env("RAZORPAY_KEY_ID"),
|
||||
keySecret: env("RAZORPAY_KEY_SECRET"),
|
||||
webhookSecret: env("RAZORPAY_WEBHOOK_SECRET"),
|
||||
theme: { color: "#0e7c86" },
|
||||
},
|
||||
acme: { apiKey: env("ACME_KEY"), webhookSecret: env("ACME_WEBHOOK_SECRET") },
|
||||
},
|
||||
|
||||
// Optional: pick a gateway per payment instead of always using the default.
|
||||
route(intent) {
|
||||
if (intent.currency === "INR") return "razorpay";
|
||||
if (intent.country === "US") return "stripe";
|
||||
return "stripe";
|
||||
},
|
||||
|
||||
sandbox: process.env.NODE_ENV !== "production",
|
||||
}
|
||||
```
|
||||
|
||||
Selection has three levels, most specific winning: an explicit `gateway` on the call, then
|
||||
`route()`, then `default`. A `route()` that returns a gateway which is not configured, or which
|
||||
cannot settle the intent's currency, is a startup error where it can be detected statically and
|
||||
a named refusal where it cannot.
|
||||
|
||||
**Registering a custom adapter is a config line, not a fork:**
|
||||
|
||||
```ts
|
||||
import { acme } from "./payments/acme.ts";
|
||||
payment: { adapters: [acme], default: "acme", /* … */ }
|
||||
```
|
||||
|
||||
**Refuse to boot on a misconfiguration rather than failing at the first payment.** A live secret
|
||||
key with `sandbox: true`, or a missing webhook secret, is a startup error naming the variable —
|
||||
the same lesson as `APP_ENCRYPTION_KEY` failing as an opaque WebCrypto error until it was made
|
||||
explicit.
|
||||
|
||||
## The helper surface
|
||||
|
||||
```ts
|
||||
// Lifecycle
|
||||
initializePayment(input): Promise<PaymentIntent> // amount, currency, subject, metadata, idempotencyKey
|
||||
confirmPayment(id): Promise<PaymentIntent> // server-side confirm where the gateway needs it
|
||||
capturePayment(id, amount?): Promise<PaymentIntent>// for auth-then-capture flows
|
||||
cancelPayment(id, reason): Promise<PaymentIntent>
|
||||
checkPayment(id): Promise<PaymentStatus> // derived from events, never from a client claim
|
||||
syncPayment(id): Promise<PaymentIntent> // authoritative re-read from the gateway
|
||||
|
||||
// Refunds
|
||||
refundPayment({ id, amount?, reason, idempotencyKey }): Promise<Refund> // partial by default
|
||||
listRefunds(id): Promise<Refund[]>
|
||||
refundableAmount(id): Promise<Money> // amount minus refunds already settled
|
||||
|
||||
// Stored methods
|
||||
paymentMethods(userId): Promise<PaymentMethod[]>
|
||||
attachPaymentMethod(userId, token): Promise<PaymentMethod>
|
||||
detachPaymentMethod(methodId): Promise<void>
|
||||
setDefaultPaymentMethod(userId, methodId): Promise<void>
|
||||
|
||||
// Records and reporting
|
||||
getPayment(id) / listPayments(filter) // filter by user, status, gateway, date range
|
||||
paymentTotals(filter): Promise<{ captured, refunded, net, byCurrency }>
|
||||
reconcilePayments(range): Promise<Discrepancy[]> // local vs gateway, the operator's safety net
|
||||
|
||||
// Webhooks
|
||||
paymentWebhookHandler(gatewayId): RouteHandler // signature-verified, idempotent, replay-safe
|
||||
```
|
||||
|
||||
Every function that moves money is **non-throwing and returns a result** with a `fault`
|
||||
discriminator, matching `@wrnexus/metering`: a refusal's reason is safe to show a customer, a
|
||||
fault's reason belongs only in the log. That distinction was learned the hard way — a naive
|
||||
catch once answered "payment required" with a raw SQLite message.
|
||||
|
||||
### `refundableAmount` earns its place
|
||||
|
||||
Partial refunds are where integrations quietly go wrong: two concurrent partial refunds each
|
||||
check "is there enough left?", both see yes, and together exceed the capture. `refundPayment`
|
||||
must enforce the cap with a conditional write — the same shape as metering's reserve — not with
|
||||
a read-then-write.
|
||||
|
||||
## Migrations ship with the package
|
||||
|
||||
`@wrnexus/authz` provisioning nothing is a real cost in this codebase: every application
|
||||
hand-wires `ensureAuthzTables`, and the framework's own example copies DDL by hand and silently
|
||||
drifts. Payment must not repeat that.
|
||||
|
||||
The package is a **plugin**. It contributes its migrations, its webhook route, and its authz
|
||||
permissions (`payment:read`, `payment:refund`, `payment:configure`) on install. An application
|
||||
adds a config block and gets a working, guarded, migrated payment system.
|
||||
|
||||
## UI components
|
||||
|
||||
Every component mounts gateway-hosted fields; none collects card data itself.
|
||||
|
||||
| Component | Does |
|
||||
| ------------------- | -------------------------------------------------------------------------------- |
|
||||
| `PayNow` | the button: opens a gateway session, shows pending/success/failure, emits `paid` |
|
||||
| `PaymentSheet` | hosted fields inline, with the gateway's own validation surfaced |
|
||||
| `PaymentMethodList` | stored methods, set-default, detach — brand and last four only |
|
||||
| `PaymentStatus` | live status for one intent, driven by `checkPayment` |
|
||||
| `RefundButton` | admin-side, requires a reason, shows `refundableAmount` |
|
||||
| `PaymentHistory` | a customer's payments and refunds |
|
||||
| `PaymentSummary` | totals by status and currency for a range |
|
||||
|
||||
`PayNow` needs to survive the customer closing the tab mid-payment, so its resolved state comes
|
||||
from `checkPayment`, not from whether the callback fired.
|
||||
|
||||
**One caveat to state plainly:** these components will hit `GAP-01` today. A `PaymentStatus`
|
||||
that first appears client-side renders as an empty placeholder, and a `PayNow` whose props change
|
||||
after mount will not update. Until the client component runtime lands, these components must be
|
||||
built server-rendered-first with real navigation, exactly as the Sendline admin console was.
|
||||
|
||||
## Admin surface
|
||||
|
||||
A payments console — list and filter, view one payment with its full event timeline, issue a
|
||||
refund with a required reason, inspect webhook deliveries and replay a failed one, and run
|
||||
reconciliation. Every privileged action writes an audit entry, so the package should either take
|
||||
a dependency on an audit interface or define one.
|
||||
|
||||
## Verification — the standard this project now holds
|
||||
|
||||
Unit and integration tests are necessary and insufficient. The package is done when:
|
||||
|
||||
1. Every gateway adapter — built-in **and** third-party — passes one shared contract suite, so
|
||||
behaviour cannot drift per gateway. An adapter declaring a capability it does not honour fails
|
||||
the suite; an adapter honouring one it does not declare fails too, because a silent extra is
|
||||
how an application comes to depend on something the next gateway lacks.
|
||||
2. The sandbox adapter can drive a full lifecycle offline: initialise, webhook, capture, partial
|
||||
refund, over-refund refused, reconciliation clean.
|
||||
3. **A real payment is driven end to end in a browser against a gateway's test mode**, and the
|
||||
money is confirmed in the gateway's own dashboard — not in our database. Phase 3 proved that a
|
||||
row saying `sent` is not the same as an email arriving; a row saying `captured` is not the same
|
||||
as money moving.
|
||||
4. A replayed webhook, a duplicated initialise, and a double-clicked refund each change the
|
||||
ledger exactly once.
|
||||
5. Reconciliation over a deliberately corrupted local row reports the discrepancy rather than
|
||||
hiding it.
|
||||
|
||||
## What this package deliberately does not do
|
||||
|
||||
- **No card data, ever.** No PAN, no CVV, no expiry, in any API, table, log or component.
|
||||
- **No invented gateway.** The sandbox adapter is clearly a sandbox and says so in its UI.
|
||||
- **No subscription billing in v1.** Recurring is a genuinely separate problem — plans, proration,
|
||||
dunning, retries — and bolting it on would compromise both.
|
||||
- **No currency conversion.** Store and settle in the currency charged; a converted number in a
|
||||
ledger is a number nobody can reconcile.
|
||||
- **No silent capture of an expired authorisation.** It fails loudly.
|
||||
|
||||
## Build order
|
||||
|
||||
1. Core: schema, append-only events, status derivation, idempotency, non-throwing results
|
||||
2. The capability system, `defineGateway`, and the shared adapter contract suite
|
||||
3. The sandbox adapter — first consumer of the contract suite, and the thing every later
|
||||
adapter is checked against
|
||||
4. **Stripe, then Razorpay.** Deliberately this pair, because they differ on capture model,
|
||||
currency spread and refund semantics. Building them together is what stops the abstraction
|
||||
quietly becoming "Stripe, with names changed"
|
||||
5. Gateway selection: explicit, `route()`, default — with the misconfiguration errors
|
||||
6. The webhook route: signature verification, replay safety, event storage
|
||||
7. Refunds, including the concurrent partial-refund cap and the `partialRefund: false` path
|
||||
8. Stored methods and customers, behind `storedMethods`
|
||||
9. PayPal — the third gateway, and the real test of whether Tier 2 can be added by someone who
|
||||
did not design the abstraction
|
||||
10. Admin console and reconciliation
|
||||
11. UI components, capability-aware
|
||||
12. Browser verification against a gateway test mode
|
||||
+247
-14
@@ -91,6 +91,7 @@
|
||||
"AuthPluginOptions",
|
||||
"AuthPublicUser",
|
||||
"AuthRandom",
|
||||
"AuthRequiredError",
|
||||
"AuthResult",
|
||||
"AuthRiskDecision",
|
||||
"AuthRiskLevel",
|
||||
@@ -210,6 +211,7 @@
|
||||
"signUpSchema",
|
||||
"totpUri",
|
||||
"tryGetDefaultAuthEngine",
|
||||
"validateProductionAuthConfig",
|
||||
"verificationRequestSchema",
|
||||
"verificationTokenSchema",
|
||||
"verifyTotp"
|
||||
@@ -229,6 +231,7 @@
|
||||
],
|
||||
"./middleware": [
|
||||
"AUTH_SESSION_KEY",
|
||||
"AuthRequiredError",
|
||||
"AuthSessionOptions",
|
||||
"RequireAuthOptions",
|
||||
"authSession",
|
||||
@@ -237,7 +240,8 @@
|
||||
"getAuthSession",
|
||||
"getAuthUser",
|
||||
"isAuthenticatedContext",
|
||||
"requireAuth"
|
||||
"requireAuth",
|
||||
"requireAuthUser"
|
||||
],
|
||||
"./passkeys": [
|
||||
"MemoryPasskeyChallengeStore",
|
||||
@@ -252,11 +256,13 @@
|
||||
"./plugin": [
|
||||
"AuthAuditIssue",
|
||||
"AuthConfig",
|
||||
"AuthOAuthProviderConfig",
|
||||
"AuthPluginOptions",
|
||||
"AuthRoutesConfig",
|
||||
"authComponentsDir",
|
||||
"authPlugin",
|
||||
"default"
|
||||
"default",
|
||||
"validateProductionAuthConfig"
|
||||
],
|
||||
"./protector": [
|
||||
"createAuthSecretProtector"
|
||||
@@ -460,7 +466,9 @@
|
||||
"AUTHZ_LOCALS_KEY",
|
||||
"AttributeMeta",
|
||||
"AuthorizationDecision",
|
||||
"AuthorizationResponses",
|
||||
"AuthorizeDecisionOptions",
|
||||
"AuthorizedHandlerOptions",
|
||||
"AuthzAuditEvent",
|
||||
"AuthzAuditSink",
|
||||
"AuthzCatalog",
|
||||
@@ -476,10 +484,12 @@
|
||||
"GrantEffect",
|
||||
"GuardOptions",
|
||||
"MemoryAuditSink",
|
||||
"OwnedResourceDefinition",
|
||||
"PermissionMeta",
|
||||
"PermissionStore",
|
||||
"Policy",
|
||||
"Rbac",
|
||||
"RequestAuthorization",
|
||||
"Subject",
|
||||
"SubjectAssignments",
|
||||
"all",
|
||||
@@ -497,7 +507,9 @@
|
||||
"createAuthzResolver",
|
||||
"decideFor",
|
||||
"decision",
|
||||
"defineAuthorizedHandler",
|
||||
"defineAuthz",
|
||||
"defineOwnedResource",
|
||||
"defineRbac",
|
||||
"deniedBy",
|
||||
"deny",
|
||||
@@ -507,6 +519,7 @@
|
||||
"filterCan",
|
||||
"generatePermissionTypes",
|
||||
"getAuthzCatalog",
|
||||
"getRequestAuthorization",
|
||||
"guardPermission",
|
||||
"hasAuthzCatalog",
|
||||
"hasRole",
|
||||
@@ -525,6 +538,23 @@
|
||||
"authzMigrationSql",
|
||||
"dbPermissionStore",
|
||||
"ensureAuthzTables"
|
||||
],
|
||||
"./defaults": [
|
||||
"AuthzIdentityEvent",
|
||||
"assignDefaultAuthzRoles",
|
||||
"setDefaultAuthzRoleStore",
|
||||
"setDefaultAuthzRoles"
|
||||
],
|
||||
"./plugin": [
|
||||
"AuthzConfig",
|
||||
"authzPlugin",
|
||||
"default"
|
||||
],
|
||||
"./runtime-memory-middleware": [
|
||||
"default"
|
||||
],
|
||||
"./runtime-middleware": [
|
||||
"default"
|
||||
]
|
||||
},
|
||||
"@wrnexus/benchmark": {
|
||||
@@ -938,6 +968,7 @@
|
||||
},
|
||||
"@wrnexus/cli": {
|
||||
".": [],
|
||||
"./test-setup": [],
|
||||
"./workspace": [
|
||||
"ResolvedWorkspaceApp",
|
||||
"ResolvedWorkspaceConfig",
|
||||
@@ -1040,7 +1071,8 @@
|
||||
"rpcManifest",
|
||||
"runtimeCapabilities",
|
||||
"runtimeTypeOf",
|
||||
"serializeIslandProps"
|
||||
"serializeIslandProps",
|
||||
"stripBrowserTypes"
|
||||
]
|
||||
},
|
||||
"@wrnexus/content": {
|
||||
@@ -1083,6 +1115,7 @@
|
||||
"BackoffStrategy",
|
||||
"Bucket",
|
||||
"BudgetViolation",
|
||||
"BuiltApiRequest",
|
||||
"Bulkhead",
|
||||
"BulkheadOptions",
|
||||
"CSRF_COOKIE",
|
||||
@@ -1135,6 +1168,8 @@
|
||||
"POSTGRES_TENANT_DIRECTORY_SCHEMA",
|
||||
"PageComponent",
|
||||
"PageMeta",
|
||||
"PaginationInput",
|
||||
"PaginationOptions",
|
||||
"PerformanceBudgets",
|
||||
"PerformanceMeasurement",
|
||||
"PermissionsPolicyConfig",
|
||||
@@ -1196,6 +1231,7 @@
|
||||
"TenantResource",
|
||||
"TenantSqlClient",
|
||||
"Tracer",
|
||||
"TransactionCookie",
|
||||
"TrustedTypesConfig",
|
||||
"UploadError",
|
||||
"UploadInspectionResult",
|
||||
@@ -1203,6 +1239,7 @@
|
||||
"UploadScanner",
|
||||
"assertTenantAccess",
|
||||
"bridgeRealtime",
|
||||
"buildApiRequest",
|
||||
"cacheControl",
|
||||
"checkPerformanceBudgets",
|
||||
"collectUploads",
|
||||
@@ -1228,6 +1265,7 @@
|
||||
"escapeHtml",
|
||||
"etag",
|
||||
"executionContextFromHttp",
|
||||
"getRequestContext",
|
||||
"getUser",
|
||||
"hashPassword",
|
||||
"isRoomDefinition",
|
||||
@@ -1259,9 +1297,11 @@
|
||||
"requestId",
|
||||
"requestLogger",
|
||||
"requireAuth",
|
||||
"requireRequestContext",
|
||||
"requireTenant",
|
||||
"resilientCall",
|
||||
"resolveRequestUrl",
|
||||
"runWithRequestContext",
|
||||
"sanitizeFilename",
|
||||
"saveUpload",
|
||||
"saveUploadSecure",
|
||||
@@ -1313,6 +1353,8 @@
|
||||
"ActionClientError",
|
||||
"ActionClientOptions",
|
||||
"ActionResult",
|
||||
"ApiClientOptions",
|
||||
"ApiRequest",
|
||||
"ClientModuleScope",
|
||||
"HydrationScopeApi",
|
||||
"NAV_RUNTIME",
|
||||
@@ -1326,6 +1368,7 @@
|
||||
"callServerFunction",
|
||||
"collectRefs",
|
||||
"createActionClient",
|
||||
"createApiClient",
|
||||
"createOutputProxy",
|
||||
"createServerProxy",
|
||||
"getActionRuntime",
|
||||
@@ -1336,11 +1379,13 @@
|
||||
"invalidateClientModule",
|
||||
"invokeOutput",
|
||||
"loadClientFunctions",
|
||||
"registerOutputHandler"
|
||||
"registerOutputHandler",
|
||||
"useFetch"
|
||||
]
|
||||
},
|
||||
"@wrnexus/db": {
|
||||
".": [
|
||||
"AddSeedOptions",
|
||||
"BaseType",
|
||||
"Column",
|
||||
"ColumnDef",
|
||||
@@ -1351,6 +1396,7 @@
|
||||
"Dialect",
|
||||
"Driver",
|
||||
"ExecResult",
|
||||
"LedgerOptions",
|
||||
"Migration",
|
||||
"MigrationRunOptions",
|
||||
"MigrationSafetyIssue",
|
||||
@@ -1367,13 +1413,20 @@
|
||||
"RelationOptions",
|
||||
"Repository",
|
||||
"Row",
|
||||
"SeedDatabase",
|
||||
"SeedRow",
|
||||
"SeedStep",
|
||||
"SeedUserAccount",
|
||||
"TxHandle",
|
||||
"UpsertSeedOptions",
|
||||
"addSeedData",
|
||||
"analyzeMigrationSafety",
|
||||
"analyzeMigrations",
|
||||
"appliedMigrations",
|
||||
"applyMigrations",
|
||||
"batch",
|
||||
"closeDatabases",
|
||||
"compareAndSet",
|
||||
"countRows",
|
||||
"createDb",
|
||||
"createRepository",
|
||||
@@ -1381,6 +1434,8 @@
|
||||
"cursorPaginate",
|
||||
"databaseHealth",
|
||||
"databaseNames",
|
||||
"defineLedger",
|
||||
"defineSeed",
|
||||
"exists",
|
||||
"firstOrThrow",
|
||||
"generateQueriesFile",
|
||||
@@ -1398,15 +1453,20 @@
|
||||
"queryOperation",
|
||||
"registerDb",
|
||||
"registerLazyDb",
|
||||
"removeSeedData",
|
||||
"resetDbPerformanceSnapshot",
|
||||
"retryTransaction",
|
||||
"rollback",
|
||||
"runSeedQuery",
|
||||
"scaffoldMigration",
|
||||
"seedIfMissing",
|
||||
"seedUsers",
|
||||
"setDb",
|
||||
"softDeleteClause",
|
||||
"status",
|
||||
"table",
|
||||
"tenantScope",
|
||||
"upsertSeedData",
|
||||
"v",
|
||||
"withTransaction"
|
||||
],
|
||||
@@ -1464,6 +1524,7 @@
|
||||
"startServer",
|
||||
"toRequest",
|
||||
"validateRpcCsrf",
|
||||
"withServerFnRequestContext",
|
||||
"writeResponse"
|
||||
],
|
||||
"./serve-entry": []
|
||||
@@ -1606,6 +1667,7 @@
|
||||
"EncryptionKey",
|
||||
"EncryptionKeyring",
|
||||
"ReplayStore",
|
||||
"SecretResourceOptions",
|
||||
"createEncryptedRequest",
|
||||
"createKeyring",
|
||||
"createMemoryReplayStore",
|
||||
@@ -1613,6 +1675,7 @@
|
||||
"decryptEncryptedResponse",
|
||||
"decryptHttpBody",
|
||||
"decryptRequest",
|
||||
"defineSecretResource",
|
||||
"deriveKey",
|
||||
"encrypt",
|
||||
"encryptHttpBody",
|
||||
@@ -1646,26 +1709,39 @@
|
||||
"@wrnexus/helpers": {
|
||||
".": [
|
||||
"AllowedHosts",
|
||||
"ApiHandler",
|
||||
"HttpError",
|
||||
"LoginRedirectOptions",
|
||||
"OriginalRequestOptions",
|
||||
"RequestContext",
|
||||
"ResourceDefinition",
|
||||
"ResourceHandlers",
|
||||
"RetryOptions",
|
||||
"appOrigin",
|
||||
"appUrl",
|
||||
"authorized",
|
||||
"backoffDelay",
|
||||
"clamp",
|
||||
"currentAppName",
|
||||
"currentAppOrigin",
|
||||
"defineApiRoute",
|
||||
"defineResource",
|
||||
"getOriginalRequestMethod",
|
||||
"getOriginalRequestOrigin",
|
||||
"getOriginalRequestPath",
|
||||
"getOriginalRequestUrl",
|
||||
"json",
|
||||
"once",
|
||||
"redirectToLogin",
|
||||
"requireJson",
|
||||
"requireParam",
|
||||
"requirePermission",
|
||||
"requireUser",
|
||||
"retry",
|
||||
"safeJsonParse",
|
||||
"sleep",
|
||||
"stableStringify",
|
||||
"subjectId",
|
||||
"withTimeout",
|
||||
"workspaceAppOrigins",
|
||||
"workspaceRootDomain"
|
||||
@@ -1833,7 +1909,7 @@
|
||||
"Position",
|
||||
"Range",
|
||||
"TextDocument",
|
||||
"WRN_COMPLETIONS",
|
||||
"WRN_KEYWORDS",
|
||||
"WorkspaceCompletionItem",
|
||||
"clearWorkspaceIndexCache",
|
||||
"completionItems",
|
||||
@@ -1854,7 +1930,31 @@
|
||||
"workspaceCompletionItems",
|
||||
"workspaceSymbolLocations"
|
||||
],
|
||||
"./server": []
|
||||
"./server": [
|
||||
"ApiCallCompletionItem",
|
||||
"apiCallCompletions",
|
||||
"apiCallHover",
|
||||
"isApiReferenceAt"
|
||||
]
|
||||
},
|
||||
"@wrnexus/mail": {
|
||||
".": [
|
||||
"MailDriver",
|
||||
"MailMessage",
|
||||
"MailOptions",
|
||||
"MailSandboxDecision",
|
||||
"MailTemplate",
|
||||
"SealedMailCredentialsOptions",
|
||||
"SealedMailDriverOptions",
|
||||
"defineMail",
|
||||
"defineMailTemplate",
|
||||
"defineSealedMailCredentials",
|
||||
"interpolateTemplate",
|
||||
"mailSandboxDecision",
|
||||
"queuedMail",
|
||||
"sealedMailDriver",
|
||||
"storedMailTemplate"
|
||||
]
|
||||
},
|
||||
"@wrnexus/mcp": {
|
||||
".": [
|
||||
@@ -1867,6 +1967,20 @@
|
||||
"runMcpStdio"
|
||||
]
|
||||
},
|
||||
"@wrnexus/metering": {
|
||||
".": [
|
||||
"EntitlementPlan",
|
||||
"EntitlementsOptions",
|
||||
"MeterKind",
|
||||
"MeterOptions",
|
||||
"MeterPack",
|
||||
"MeterResult",
|
||||
"MeterStore",
|
||||
"defineEntitlements",
|
||||
"defineMeter",
|
||||
"definePacks"
|
||||
]
|
||||
},
|
||||
"@wrnexus/mobile": {
|
||||
".": [
|
||||
"CapacitorBridge",
|
||||
@@ -2067,6 +2181,70 @@
|
||||
"traceMiddleware"
|
||||
]
|
||||
},
|
||||
"@wrnexus/payment": {
|
||||
".": [
|
||||
"CreateIntentInput",
|
||||
"CustomerInput",
|
||||
"GatewayCapabilities",
|
||||
"GatewayContractFixture",
|
||||
"GatewayContractReport",
|
||||
"GatewayIntent",
|
||||
"GatewayMethod",
|
||||
"GatewayRefund",
|
||||
"GatewaySupport",
|
||||
"InitializePaymentInput",
|
||||
"Money",
|
||||
"PayPalConfig",
|
||||
"PaymentEvent",
|
||||
"PaymentGateway",
|
||||
"PaymentIntent",
|
||||
"PaymentMethod",
|
||||
"PaymentOptions",
|
||||
"PaymentRefund",
|
||||
"PaymentResult",
|
||||
"PaymentService",
|
||||
"PaymentStatus",
|
||||
"PaymentStore",
|
||||
"RazorpayConfig",
|
||||
"RefundInput",
|
||||
"SandboxOptions",
|
||||
"StripeConfig",
|
||||
"WebhookEvent",
|
||||
"configurePayment",
|
||||
"databasePaymentStore",
|
||||
"defineGateway",
|
||||
"definePayment",
|
||||
"derivePaymentStatus",
|
||||
"exerciseGatewayContract",
|
||||
"getPaymentService",
|
||||
"memoryPaymentStore",
|
||||
"paymentPlugin",
|
||||
"paypalGateway",
|
||||
"razorpayGateway",
|
||||
"sandboxGateway",
|
||||
"signSandboxWebhook",
|
||||
"stripeGateway",
|
||||
"verifyGatewayContract"
|
||||
],
|
||||
"./adapters": [
|
||||
"PayPalConfig",
|
||||
"RazorpayConfig",
|
||||
"SandboxOptions",
|
||||
"StripeConfig",
|
||||
"paypalGateway",
|
||||
"razorpayGateway",
|
||||
"sandboxGateway",
|
||||
"signSandboxWebhook",
|
||||
"stripeGateway"
|
||||
],
|
||||
"./plugin": [
|
||||
"default",
|
||||
"paymentPlugin"
|
||||
],
|
||||
"./runtime-webhook": [
|
||||
"POST"
|
||||
]
|
||||
},
|
||||
"@wrnexus/playground": {
|
||||
".": [
|
||||
"PlaygroundCompilation",
|
||||
@@ -2231,23 +2409,44 @@
|
||||
"@wrnexus/queue": {
|
||||
".": [
|
||||
"AddOptions",
|
||||
"DefineQueueOptions",
|
||||
"DefinedJob",
|
||||
"DefinedJobOptions",
|
||||
"DefinedJobs",
|
||||
"DefinedQueue",
|
||||
"DurableQueue",
|
||||
"DurableQueueOptions",
|
||||
"Job",
|
||||
"JobContext",
|
||||
"JobDefinition",
|
||||
"JobHandler",
|
||||
"JobStateMachineOptions",
|
||||
"JobStatus",
|
||||
"OutboxEntry",
|
||||
"OutboxWriter",
|
||||
"POSTGRES_QUEUE_SCHEMA",
|
||||
"Queue",
|
||||
"QueueAdminOptions",
|
||||
"QueueDashboardSnapshot",
|
||||
"QueueEvent",
|
||||
"QueueEventType",
|
||||
"QueueHealth",
|
||||
"QueueJobDefinitions",
|
||||
"QueueJobRecord",
|
||||
"QueueJobState",
|
||||
"QueueOptions",
|
||||
"QueueScheduler",
|
||||
"QueueStorage",
|
||||
"QueueStorageConfig",
|
||||
"QueueStore",
|
||||
"RedisQueueClient",
|
||||
"SagaStep",
|
||||
"ScheduledJob",
|
||||
"SqlQueueClient",
|
||||
"SqliteQueueClient",
|
||||
"SubjectJob",
|
||||
"SubjectQueue",
|
||||
"WorkerDefinition",
|
||||
"WorkflowDefinition",
|
||||
"WorkflowEngine",
|
||||
"WorkflowRunContext",
|
||||
@@ -2256,22 +2455,46 @@
|
||||
"WorkflowStep",
|
||||
"WorkflowStore",
|
||||
"addBatch",
|
||||
"configureQueueStorage",
|
||||
"configuredQueueStore",
|
||||
"createDurableQueue",
|
||||
"createQueue",
|
||||
"createQueueAdminHandler",
|
||||
"createQueueScheduler",
|
||||
"createWorkflowEngine",
|
||||
"cronToInterval",
|
||||
"databaseQueueStore",
|
||||
"defineDurableWorkflow",
|
||||
"defineJob",
|
||||
"defineJobStateMachine",
|
||||
"defineQueue",
|
||||
"defineSaga",
|
||||
"defineWorker",
|
||||
"defineWorkflow",
|
||||
"drainOutbox",
|
||||
"installDatabaseQueueSchema",
|
||||
"installOutboxSchema",
|
||||
"installSqliteQueueSchema",
|
||||
"memoryQueueStore",
|
||||
"memoryWorkflowStore",
|
||||
"postgresQueueStore",
|
||||
"queueDashboardSnapshot",
|
||||
"queueStorageConfig",
|
||||
"redisQueueStore",
|
||||
"renderQueueDashboard",
|
||||
"runQueueDaemon",
|
||||
"subjectQueue"
|
||||
"runWorker",
|
||||
"sqliteQueueSchema",
|
||||
"sqliteQueueStore",
|
||||
"subjectQueue",
|
||||
"testQueue",
|
||||
"withOutbox"
|
||||
],
|
||||
"./sqlite": [
|
||||
"SqliteQueueClient",
|
||||
"installSqliteQueueSchema",
|
||||
"sqliteQueueSchema",
|
||||
"sqliteQueueStore"
|
||||
]
|
||||
},
|
||||
"@wrnexus/react": {
|
||||
@@ -2314,6 +2537,7 @@
|
||||
".": [
|
||||
"AnimationTimeline",
|
||||
"Cleanup",
|
||||
"FormState",
|
||||
"HistorySignal",
|
||||
"ReactiveContext",
|
||||
"ReactiveScope",
|
||||
@@ -2340,6 +2564,7 @@
|
||||
"transition",
|
||||
"untrack",
|
||||
"urlSignal",
|
||||
"useForm",
|
||||
"watch"
|
||||
]
|
||||
},
|
||||
@@ -2627,15 +2852,12 @@
|
||||
".": [
|
||||
"ACCENT_COOKIE",
|
||||
"AppConfig",
|
||||
"AppConfigInput",
|
||||
"BrowserCookieApi",
|
||||
"BrowserCookieOptions",
|
||||
"BrowserCookiePreference",
|
||||
"BrowserCookiesConfig",
|
||||
"BuildConfig",
|
||||
"CURRENT_COMPATIBILITY_DATE",
|
||||
"CURRENT_FRAMEWORK_BEHAVIOUR",
|
||||
"CompatibilityPolicy",
|
||||
"CompatibilityReport",
|
||||
"ConfigIssue",
|
||||
"ContrastResult",
|
||||
"CssPerformanceAuditIssue",
|
||||
@@ -2655,6 +2877,7 @@
|
||||
"ObservabilityConfig",
|
||||
"PerformanceConfig",
|
||||
"PwaConfig",
|
||||
"ResolvedAppConfig",
|
||||
"ResolvedConfigLayers",
|
||||
"ResolvedTheme",
|
||||
"StyleProcessContext",
|
||||
@@ -2686,7 +2909,6 @@
|
||||
"findStyleEntry",
|
||||
"fontCspSources",
|
||||
"headToString",
|
||||
"isCompatibilityDate",
|
||||
"loadAppConfig",
|
||||
"loadEnv",
|
||||
"loadRawConfig",
|
||||
@@ -2699,7 +2921,6 @@
|
||||
"renderThemeRuntime",
|
||||
"resolveAccentName",
|
||||
"resolveBrowserCookieOptions",
|
||||
"resolveCompatibility",
|
||||
"resolveConfigLayers",
|
||||
"resolveProfile",
|
||||
"resolveThemeConfig",
|
||||
@@ -2787,6 +3008,7 @@
|
||||
"parseStructuredImports",
|
||||
"positionAt",
|
||||
"runtimeTypeOf",
|
||||
"skipLiteralOrComment",
|
||||
"sliceSource",
|
||||
"stripRuntimeFunctionModifiers",
|
||||
"supportsSyntaxFeature",
|
||||
@@ -2853,7 +3075,10 @@
|
||||
"LexError",
|
||||
"Lexer",
|
||||
"Token",
|
||||
"TokenType"
|
||||
"TokenType",
|
||||
"isIdentPart",
|
||||
"isIdentStart",
|
||||
"skipLiteralOrComment"
|
||||
],
|
||||
"./types": [
|
||||
"RuntimeType",
|
||||
@@ -2867,10 +3092,14 @@
|
||||
".": [
|
||||
"BrowserArtifactPage",
|
||||
"Deferred",
|
||||
"DetectMutationsOptions",
|
||||
"FetchMock",
|
||||
"Harness",
|
||||
"HarnessOptions",
|
||||
"JsonResponse",
|
||||
"MemoryCookieJar",
|
||||
"MutationCase",
|
||||
"MutationReport",
|
||||
"TestRequestOptions",
|
||||
"TransactionalDatabase",
|
||||
"WaitForOptions",
|
||||
@@ -2882,9 +3111,13 @@
|
||||
"captureBrowserArtifacts",
|
||||
"createContext",
|
||||
"createFactory",
|
||||
"createFetchMock",
|
||||
"createHarness",
|
||||
"createTestApp",
|
||||
"createTestContext",
|
||||
"deferred",
|
||||
"describe",
|
||||
"detectMutations",
|
||||
"expect",
|
||||
"expectProblem",
|
||||
"it",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,400 @@
|
||||
# Editor Tooling Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** The language server and VS Code extension understand `apis { }`, complete `api.<name>()`, flag the removed constructs, and stop offering syntax the compiler rejects.
|
||||
|
||||
**Architecture:** Four surfaces change independently — the TextMate grammar, the keyword and snippet completions, the server's call completion and hover, and diagnostics for removed constructs. A final task answers by observation whether generated type errors surface inside `.wrn`, which has never been verified.
|
||||
|
||||
**Tech Stack:** Bun, TypeScript, `bun:test`, `node --test`, LSP, TextMate grammars.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-19-editor-tooling-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `client` is one word with several jobs: `client state { }`, `runtime = "client"`, and the `client function` modifier all survive. **Only the `client { }` / `ssr { }` data-block patterns are removed.** Blanket removal would un-highlight constructs that still exist.
|
||||
- Offering a construct the compiler rejects is worse than offering nothing.
|
||||
- **Nothing may claim inline diagnostics work until someone has seen them work.**
|
||||
- The editor bundles embed the compiler and language server — rebuild with `bun run --cwd editors/vscode build`, or the `check:editor-*` gates fail on a stale bundle.
|
||||
- `bun run format` before every commit; the gate is `bun run check:production`.
|
||||
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Grammar
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `editors/vscode/syntaxes/wrn.tmLanguage.json`
|
||||
- Test: `editors/vscode/test/grammar-apis.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `apis` highlights as a block keyword; entries highlight as declarations.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `editors/vscode/test/grammar-apis.test.js`:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
const grammar = readFileSync(join(__dirname, "../syntaxes/wrn.tmLanguage.json"), "utf8");
|
||||
|
||||
test("the grammar knows the apis block", () => {
|
||||
assert.ok(grammar.includes("apis"), "apis should appear as a block keyword");
|
||||
});
|
||||
|
||||
test("client keeps its highlighting where it is still valid", () => {
|
||||
// client state {}, runtime = "client", and client function all survive.
|
||||
// Only the client {} data block was removed.
|
||||
assert.ok(grammar.includes("client"), "client must still be matched");
|
||||
assert.ok(grammar.includes("shared"), "the shared function modifier must still be matched");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `node --test editors/vscode/test/grammar-apis.test.js`
|
||||
Expected: FAIL on the first assertion — `apis` is absent.
|
||||
|
||||
- [ ] **Step 3: Add `apis`, remove only the data-block patterns**
|
||||
|
||||
Add `apis` to the block-keyword pattern alongside `functions`. Then find the patterns matching `ssr`/`client` as **data blocks** and remove only those. Leave every rule that matches `client` in `client state`, in `runtime` values, and as a function modifier.
|
||||
|
||||
Add a pattern for an entry — `<name> <METHOD> <path>` — so a declaration reads as a declaration.
|
||||
|
||||
- [ ] **Step 4: Run the test and check by eye**
|
||||
|
||||
Run: `node --test editors/vscode/test/grammar-apis.test.js`
|
||||
Expected: PASS. Then open a `.wrn` file using `apis { }`, `client state { }`, `functions { shared function }`, and `runtime = "client"` in VS Code and confirm each still colours correctly. Record what you saw.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add editors/vscode
|
||||
git commit -m "feat(editor): highlight the apis block"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Keyword and snippet completion
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/language-server/src/index.ts` (`WRN_KEYWORDS`, ~line 31)
|
||||
- Modify: `editors/vscode/src/completion.js` (block snippets)
|
||||
- Test: `packages/language-server/test/apis-completion.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces: `apis` is a known keyword; `ssr` / `client` data-block snippets are gone.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/language-server/test/apis-completion.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { WRN_KEYWORDS } from "../src/index.ts";
|
||||
|
||||
test("apis is a known page-level keyword", () => {
|
||||
expect(WRN_KEYWORDS).toContain("apis");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/language-server/test/apis-completion.test.ts`
|
||||
Expected: FAIL — `apis` is missing.
|
||||
|
||||
- [ ] **Step 3: Add the keyword and the snippets**
|
||||
|
||||
Add `"apis"` to `WRN_KEYWORDS`. In `editors/vscode/src/completion.js`, add a container snippet and an entry snippet including the `request` / `response` / `error` sections, and remove any `ssr {` / `client {` data-block snippet.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/language-server && bun run --cwd editors/vscode test`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/language-server editors/vscode
|
||||
git commit -m "feat(editor): complete the apis block and drop the removed snippets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `api.` call completion and hover
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/language-server/src/server.ts` (completion and hover handlers)
|
||||
- Test: `packages/language-server/test/api-call-completion.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `ast.dataApis` entries, which carry `name`, `method`, `path`, and `sections`.
|
||||
- Produces: completion items for `api.` and hover detail for a block name.
|
||||
|
||||
This is where the syntax pays off in the editor: the set of legal calls is knowable, so the editor should know it.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/language-server/test/api-call-completion.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { apiCallCompletions, apiCallHover } from "../src/server.ts";
|
||||
|
||||
const SOURCE = `page Search {
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
}
|
||||
|
||||
listTeams GET /api/teams {
|
||||
response { return data.teams }
|
||||
}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function go(): Promise<void> {
|
||||
await api.
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("api. offers every declared block with method and path", () => {
|
||||
const items = apiCallCompletions(SOURCE);
|
||||
const labels = items.map((item) => item.label);
|
||||
|
||||
expect(labels).toContain("searchUsers");
|
||||
expect(labels).toContain("listTeams");
|
||||
|
||||
const search = items.find((item) => item.label === "searchUsers")!;
|
||||
expect(search.detail).toContain("POST");
|
||||
expect(search.detail).toContain("/api/users");
|
||||
});
|
||||
|
||||
test("hovering a block name reports its method, path and request fields", () => {
|
||||
const hover = apiCallHover(SOURCE, "searchUsers");
|
||||
|
||||
expect(hover).toContain("POST");
|
||||
expect(hover).toContain("/api/users");
|
||||
expect(hover).toContain("name");
|
||||
});
|
||||
|
||||
test("a page with no apis block offers nothing", () => {
|
||||
expect(apiCallCompletions(`page P { view { <main>x</main> } }`)).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/language-server/test/api-call-completion.test.ts`
|
||||
Expected: FAIL — the functions do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement and export both functions**
|
||||
|
||||
Add `apiCallCompletions(source: string)` and `apiCallHover(source: string, name: string)` to `packages/language-server/src/server.ts`, parsing with `@wrnexus/syntax` and reading `ast.dataApis`. Wire `apiCallCompletions` into the `textDocument/completion` handler for positions immediately after `api.`, and `apiCallHover` into `textDocument/hover`.
|
||||
|
||||
The parser must tolerate the half-typed `await api.` in the fixture. If it throws, fall back to returning `[]` rather than failing the request — completion fires while the document does not parse, which is the normal case.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/language-server`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/language-server
|
||||
git commit -m "feat(language-server): complete and describe api block calls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: The `api=` attribute in markup
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/language-server/src/html-service.ts`
|
||||
- Test: `packages/language-server/test/api-attribute.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `apiCallCompletions` from Task 3.
|
||||
- Produces: `api="…"` is not flagged as unknown, and completion inside the quotes offers block names.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/language-server/test/api-attribute.test.ts` asserting that (a) an `api="searchUsers"` attribute produces no unknown-attribute diagnostic, and (b) completion inside the quotes offers `searchUsers`. Reuse the fixture shape from Task 3.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/language-server/test/api-attribute.test.ts`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Teach the HTML service about the attribute**
|
||||
|
||||
Treat `api` as a known attribute on any element, and route completion inside its quotes to `apiCallCompletions`. The value is a call expression, not text — it must not be spell-checked or reformatted as prose.
|
||||
|
||||
- [ ] **Step 4: Run the tests and commit**
|
||||
|
||||
```bash
|
||||
bun test packages/language-server
|
||||
bun run format
|
||||
git add packages/language-server
|
||||
git commit -m "feat(language-server): understand the api binding attribute"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Diagnostics for the removed constructs
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/language-server/src/diagnostics` entry point (wherever `.wrn` diagnostics are produced)
|
||||
- Test: `packages/language-server/test/removed-construct-diagnostics.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: an `ssr { api … }` or `client { api … }` block yields a diagnostic naming `apis { }`, positioned on the block keyword.
|
||||
|
||||
The compiler already rejects these. The editor should say so while typing, and say what to do instead.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/language-server/test/removed-construct-diagnostics.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { diagnoseWrn } from "../src/index.ts";
|
||||
|
||||
test("an ssr data block is flagged and names the replacement", () => {
|
||||
const diagnostics = diagnoseWrn(`page P {
|
||||
ssr { api x GET /api/x { response { return data } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`);
|
||||
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0]!.message).toContain("apis");
|
||||
});
|
||||
|
||||
test("client state is not flagged", () => {
|
||||
const diagnostics = diagnoseWrn(`page P {
|
||||
client state { count = 0 }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`);
|
||||
|
||||
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
Use whichever diagnostic entry point the language server exports; keep the assertions identical.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/language-server/test/removed-construct-diagnostics.test.ts`
|
||||
Expected: FAIL — either no diagnostic, or one that does not name `apis`.
|
||||
|
||||
- [ ] **Step 3: Surface the parse error as a diagnostic**
|
||||
|
||||
The parser already throws a message naming `apis { }` for these blocks. Ensure that message reaches the diagnostic with a position on the offending keyword rather than at offset zero.
|
||||
|
||||
- [ ] **Step 4: Run the tests and commit**
|
||||
|
||||
```bash
|
||||
bun test packages/language-server
|
||||
bun run format
|
||||
git add packages/language-server
|
||||
git commit -m "feat(language-server): flag the removed data blocks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Answer the inline-diagnostics question, then the full gate
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: whatever the observation in Step 2 shows is needed, or none
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Tasks 1-5.
|
||||
|
||||
The `apis` plan generates type assertions that make `tsc` fail when a block declares a field its endpoint rejects. **Whether that failure appears inside the `.wrn` file has never been confirmed** — it was inferred from reading source. This task settles it by looking.
|
||||
|
||||
- [ ] **Step 1: Rebuild the bundles**
|
||||
|
||||
```bash
|
||||
bun run --cwd editors/vscode build
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Observe, and write down what you see**
|
||||
|
||||
In `examples/basic-app`, add a field to an `apis { }` entry that its endpoint does not accept, and run `bun run --cwd examples/basic-app wrnexus generate types`. Open the page in VS Code and record exactly where the error appears: on the block, only in `app/types/wrnexus.generated.api-checks.ts`, or nowhere.
|
||||
|
||||
Write the answer into the task report. **Do not skip this step and reason about it instead** — that is what left the question open the first time.
|
||||
|
||||
- [ ] **Step 3: Act on what you observed**
|
||||
|
||||
If the error already surfaces usefully on the block, document it and stop.
|
||||
|
||||
If it appears only in the generated file, map the diagnostic back: the generator knows which page and block produced each assertion, so record that mapping when emitting and use it to relocate the diagnostic.
|
||||
|
||||
If that mapping proves larger than this task can hold, **stop and report it as follow-up work** rather than half-building it. Say so plainly in the report.
|
||||
|
||||
- [ ] **Step 4: Remove the temporary field**
|
||||
|
||||
Revert the deliberate error and confirm `bun run typecheck` passes with zero net diff in `examples/basic-app`.
|
||||
|
||||
- [ ] **Step 5: Full gate**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun test
|
||||
bun run typecheck
|
||||
bun run --cwd editors/vscode build
|
||||
bun run --cwd editors/vscode test
|
||||
bun run check:production
|
||||
```
|
||||
|
||||
Expected: exit 0 throughout, including `check:editor-compiler`, `check:editor-language-server`, and `check:editor-extension`.
|
||||
|
||||
- [ ] **Step 6: Manual pass, recorded**
|
||||
|
||||
Open the migrated `examples/basic-app` in VS Code and confirm: `apis { }` highlights, `api.` completes with the page's block names, hovering a name shows its method and path, and an `ssr { api … }` block is flagged. Record what you saw in the report — including anything that did not work.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(editor): complete tooling support for the apis block"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **`client` is not one thing.** Removing every `client` rule from the grammar would break `client state`, `runtime = "client"`, and `client function`. Only the data-block patterns go.
|
||||
- **The parser must tolerate half-typed input.** Completion fires while the document does not parse; a thrown error must become an empty completion list, not a failed request.
|
||||
- **Step 2 of Task 6 is an observation, not a deduction.** Open the editor and look.
|
||||
- **If a test would still pass with the code it guards deleted, it is not a test.**
|
||||
@@ -0,0 +1,537 @@
|
||||
# Legacy and Config Cleanup Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Delete the compatibility-flag surface, the `"legacy"` function runtime, dead migrations, and deprecated APIs before the framework's first public release.
|
||||
|
||||
**Architecture:** Almost all of this is deletion. Seven config keys are never read by any code, so removing them changes nothing. The one behaviour-sensitive item is the `"legacy"` function runtime, which is mapped to `"shared"` — an equivalent substitution, because an unmarked function is already emitted into both bundles.
|
||||
|
||||
**Tech Stack:** Bun, TypeScript, `bun:test`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-19-legacy-and-config-cleanup-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- This plan removes configuration. It adds none.
|
||||
- The legacy `api` block forms (bare-body `with ($data)`, and `api` inside `ssr {}` / `client {}`) are **out of scope** — they are replaced by the next plan, not deleted here.
|
||||
- A removed config key must be **rejected loudly**, not silently ignored. Someone with a stale config must be told, not left believing a flag still applies.
|
||||
- `bun run format` before every commit; the repo gate is `bun run check:production`.
|
||||
- The editor bundles embed the compiler — rebuild with `bun run --cwd editors/vscode build` after any `packages/syntax` or `packages/compiler` change, or `check:editor-compiler` fails on a stale bundle.
|
||||
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files; escaping mangles them silently. Use file editing tools.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Replace the `"legacy"` function runtime with `"shared"`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/syntax/src/v060.ts` (the `FunctionRuntime` type; the default at ~line 209)
|
||||
- Modify: `packages/compiler/src/client-codegen.ts` (membership tests at ~lines 173 and 340)
|
||||
- Modify: `packages/compiler/src/server-codegen.ts` (the `["legacy", "server", "shared"]` list)
|
||||
- Modify: `packages/compiler/src/codegen.ts` (`targetFunctions`, ~line 1310)
|
||||
- Test: `packages/compiler/test/legacy-runtime-removal.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `FunctionRuntime` becomes `"client" | "server" | "shared"`. Later tasks and plans rely on `"legacy"` no longer existing.
|
||||
|
||||
**Why this is equivalent, not a behaviour change:** an unmarked `function foo()` currently parses as `"legacy"`, and both codegens include `"legacy"` in their membership tests — `["legacy", "client", "shared"]` for the browser and `["legacy", "server", "shared"]` for the server. So an unmarked function is already emitted into _both_ bundles, exactly like `shared`. `legacyDefaultRuntime` looks like it should modulate this but is never read.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/compiler/test/legacy-runtime-removal.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { generateTargets } from "../src/targets.ts";
|
||||
|
||||
const SOURCE = `page Probe {
|
||||
functions {
|
||||
function unmarkedHelper() {
|
||||
return "both";
|
||||
}
|
||||
|
||||
client function clientOnly() {
|
||||
return "browser";
|
||||
}
|
||||
|
||||
server function serverOnly() {
|
||||
return "server";
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("an unmarked function is emitted into both the browser and server modules", () => {
|
||||
// This is the property the "legacy" runtime provided. Removing the variant
|
||||
// must not change it.
|
||||
const targets = generateTargets(parse(SOURCE));
|
||||
|
||||
expect(targets.browser).toContain("unmarkedHelper");
|
||||
expect(targets.server).toContain("unmarkedHelper");
|
||||
});
|
||||
|
||||
test("marked functions still go only where they belong", () => {
|
||||
const targets = generateTargets(parse(SOURCE));
|
||||
|
||||
expect(targets.browser).toContain("clientOnly");
|
||||
expect(targets.browser).not.toContain("serverOnly");
|
||||
expect(targets.server).toContain("serverOnly");
|
||||
expect(targets.server).not.toContain("clientOnly");
|
||||
});
|
||||
|
||||
test("no emitted target mentions the removed legacy runtime", () => {
|
||||
const targets = generateTargets(parse(SOURCE));
|
||||
|
||||
expect(targets.browser).not.toContain('"legacy"');
|
||||
expect(targets.server).not.toContain('"legacy"');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test and record the baseline**
|
||||
|
||||
Run: `bun test packages/compiler/test/legacy-runtime-removal.test.ts`
|
||||
Expected: the first two tests PASS (they describe current behaviour and must keep passing), the third may already pass. This test file is a **regression guard written before the change**, so a green run here is correct — record the output.
|
||||
|
||||
- [ ] **Step 3: Remove the `"legacy"` variant from the type and parser**
|
||||
|
||||
In `packages/syntax/src/v060.ts`:
|
||||
|
||||
```ts
|
||||
export type FunctionRuntime = "client" | "server" | "shared";
|
||||
```
|
||||
|
||||
And at the parse site (~line 209), change the default:
|
||||
|
||||
```ts
|
||||
let runtime: FunctionRuntime = "shared";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Drop `"legacy"` from every membership test**
|
||||
|
||||
In `packages/compiler/src/client-codegen.ts`, both occurrences:
|
||||
|
||||
```ts
|
||||
["client", "shared"].includes(fn.runtime),
|
||||
```
|
||||
|
||||
In `packages/compiler/src/server-codegen.ts`:
|
||||
|
||||
```ts
|
||||
const names = ast.runtimeFunctions
|
||||
.filter((fn) => ["server", "shared"].includes(fn.runtime))
|
||||
.map((fn) => fn.name);
|
||||
```
|
||||
|
||||
In `packages/compiler/src/codegen.ts`, `targetFunctions`:
|
||||
|
||||
```ts
|
||||
const runtimes =
|
||||
target === "browser" ? (["client", "shared"] as const) : (["server", "shared"] as const);
|
||||
```
|
||||
|
||||
Search the repo for any remaining `"legacy"` in these packages and remove each — the string must not survive in `packages/syntax` or `packages/compiler`.
|
||||
|
||||
- [ ] **Step 5: Run the tests**
|
||||
|
||||
Run: `bun test packages/syntax packages/compiler`
|
||||
Expected: PASS, including the three guards from Step 1. If the first two now fail, the substitution was not equivalent — stop and report rather than adjusting the test.
|
||||
|
||||
- [ ] **Step 6: Rebuild the editor bundles and commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun run --cwd editors/vscode build
|
||||
git add packages/syntax packages/compiler editors/vscode/src
|
||||
git commit -m "refactor: replace the legacy function runtime with shared"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Delete the compatibility surface
|
||||
|
||||
**Files:**
|
||||
|
||||
- Delete: `packages/styles/src/compatibility.ts`
|
||||
- Delete: `packages/cli/src/compatibility-command.ts`
|
||||
- Delete: `packages/cli/test/compatibility.test.ts`
|
||||
- Modify: `packages/styles/src/config.ts` (`FunctionsConfig` ~233, `CompatibilityConfig` ~242, `AppConfig extends CompatibilityPolicy` ~249, the `functions?:` and `compatibility?:` members, the `resolveCompatibility` validation ~619, and the `CompatibilityPolicy` import ~23)
|
||||
- Modify: `packages/styles/src/index.ts` (the `./compatibility.ts` exports at ~lines 39-45)
|
||||
- Modify: `packages/cli/src/index.ts` (dispatch at ~line 295, help text at ~line 75)
|
||||
- Modify: `packages/cli/src/create.ts` (~lines 263, 278-283)
|
||||
- Modify: `packages/cli/src/update.ts` (the config insertion string at ~line 389)
|
||||
- Modify: `packages/styles/test/config.test.ts` (assertions on the removed keys)
|
||||
- Modify: `examples/basic-app/wrnexus.config.ts`
|
||||
- Test: `packages/styles/test/removed-config-keys.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing from Task 1.
|
||||
- Produces: `AppConfig` no longer extends `CompatibilityPolicy` and has no `compatibility` or `functions` members. `@wrnexus/styles` no longer exports `resolveCompatibility`, `isCompatibilityDate`, `CURRENT_COMPATIBILITY_DATE`, `CURRENT_FRAMEWORK_BEHAVIOUR`, `CompatibilityPolicy`, or `CompatibilityReport`.
|
||||
|
||||
**These seven keys are never read.** `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `legacyDefaultRuntime` appear only in the type declaration, `create.ts`, and `update.ts`. `compatibilityDate` and `frameworkBehaviour` feed only a printed report and one validation. Removing them changes no behaviour.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/styles/test/removed-config-keys.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { validateConfig } from "../src/config.ts";
|
||||
|
||||
// A stale config must fail loudly. Silently ignoring a removed key leaves
|
||||
// someone believing a flag still applies.
|
||||
const REMOVED = [
|
||||
{ key: "compatibilityDate", config: { compatibilityDate: "2026-08-02" } },
|
||||
{ key: "frameworkBehaviour", config: { frameworkBehaviour: 1 } },
|
||||
{ key: "functions", config: { functions: { legacyDefaultRuntime: "current" } } },
|
||||
{ key: "compatibility", config: { compatibility: { legacyEmit: false } } },
|
||||
];
|
||||
|
||||
for (const { key, config } of REMOVED) {
|
||||
test(`a config still setting "${key}" is rejected with a message naming it`, () => {
|
||||
const issues = validateConfig(config as never);
|
||||
const match = issues.find((issue) => issue.path === key || issue.path.startsWith(`${key}.`));
|
||||
|
||||
expect(match).toBeDefined();
|
||||
expect(match!.severity).toBe("error");
|
||||
expect(match!.message.toLowerCase()).toContain("removed");
|
||||
});
|
||||
}
|
||||
|
||||
test("a config without those keys is accepted", () => {
|
||||
const issues = validateConfig({} as never);
|
||||
|
||||
expect(issues.filter((issue) => issue.severity === "error")).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
If `validateConfig` is not the exported name in `packages/styles/src/config.ts`, use whichever function that module exports for validation and keep the assertions identical.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/styles/test/removed-config-keys.test.ts`
|
||||
Expected: FAIL — the keys are currently accepted, so no issue is produced.
|
||||
|
||||
- [ ] **Step 3: Delete the compatibility module and its command**
|
||||
|
||||
```bash
|
||||
git rm packages/styles/src/compatibility.ts packages/cli/src/compatibility-command.ts packages/cli/test/compatibility.test.ts
|
||||
```
|
||||
|
||||
In `packages/styles/src/index.ts`, remove the whole `./compatibility.ts` export block (both the value exports and the `export type` line).
|
||||
|
||||
In `packages/cli/src/index.ts`, remove the `case "compatibility":` dispatch and the `wrnexus compatibility …` line from the help text.
|
||||
|
||||
- [ ] **Step 4: Remove the config members and add the rejections**
|
||||
|
||||
In `packages/styles/src/config.ts`: delete the `CompatibilityPolicy` import, the `FunctionsConfig` and `CompatibilityConfig` interfaces, the `functions?:` and `compatibility?:` members of `AppConfig`, `extends CompatibilityPolicy` on `AppConfig`, and the `resolveCompatibility` validation block.
|
||||
|
||||
Then add the rejections so a stale config fails loudly:
|
||||
|
||||
```ts
|
||||
const REMOVED_CONFIG_KEYS = [
|
||||
"compatibilityDate",
|
||||
"frameworkBehaviour",
|
||||
"functions",
|
||||
"compatibility",
|
||||
] as const;
|
||||
|
||||
for (const key of REMOVED_CONFIG_KEYS) {
|
||||
if ((config as Record<string, unknown>)[key] !== undefined) {
|
||||
issues.push({
|
||||
path: key,
|
||||
severity: "error",
|
||||
message: "was removed; delete it from the configuration",
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Place this beside the other validation pushes, using whatever local variable that function accumulates issues in.
|
||||
|
||||
- [ ] **Step 5: Stop scaffolding and inserting the keys**
|
||||
|
||||
In `packages/cli/src/create.ts`, delete the `compatibilityDate`, `frameworkBehaviour`, and `functions: { legacyDefaultRuntime: … }` lines from the generated config.
|
||||
|
||||
In `packages/cli/src/update.ts` (~line 389), remove `functions: { legacyDefaultRuntime: "current" },` and the whole `compatibility: { … },` fragment from the insertion string.
|
||||
|
||||
- [ ] **Step 6: Trim the example app config**
|
||||
|
||||
In `examples/basic-app/wrnexus.config.ts`, delete `compatibilityDate`, `frameworkBehaviour`, `functions`, and `compatibility`.
|
||||
|
||||
- [ ] **Step 7: Update the existing config tests**
|
||||
|
||||
`packages/styles/test/config.test.ts` asserts on the removed keys. Remove those assertions. Do not weaken any assertion that is still meaningful — if a test only existed to cover compatibility, delete the whole test.
|
||||
|
||||
- [ ] **Step 8: Run the tests**
|
||||
|
||||
Run: `bun test packages/styles packages/cli`
|
||||
Expected: PASS, including the new rejection tests.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add -A packages/styles packages/cli examples/basic-app
|
||||
git commit -m "refactor: delete the compatibility config surface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Drop migrations below 0.8.0
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/cli/src/update.ts` (all `Migration` entries with `version` below `"0.8.0"`)
|
||||
- Test: `packages/cli/test/update-migration-floor.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces: the migration list starts at `0.8.0`.
|
||||
|
||||
`update.ts` holds 111 migrations reaching back to `0.2.8`. The framework is pre-public and the only projects run `0.8.x`, so everything below the floor is unreachable.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/update-migration-floor.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
test("no migration targets a version below 0.8.0", () => {
|
||||
const source = readFileSync(join(import.meta.dir, "../src/update.ts"), "utf8");
|
||||
const versions = [...source.matchAll(/version:\s*"([0-9.]+)"/g)].map((match) => match[1]!);
|
||||
|
||||
expect(versions.length).toBeGreaterThan(0);
|
||||
|
||||
const belowFloor = versions.filter((version) => {
|
||||
const [major, minor] = version.split(".").map(Number);
|
||||
return major! === 0 && minor! < 8;
|
||||
});
|
||||
|
||||
expect(belowFloor).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/update-migration-floor.test.ts`
|
||||
Expected: FAIL, listing the `0.2.x`–`0.7.x` versions.
|
||||
|
||||
- [ ] **Step 3: Delete the migrations below the floor**
|
||||
|
||||
Remove every `Migration` object whose `version` is below `"0.8.0"`, along with any helper function that becomes unused as a result. Keep every `0.8.x` entry.
|
||||
|
||||
After deleting, search for now-unreferenced helpers in the file and remove them too — an unused private helper is dead code, and the linter will flag it.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS. Existing update tests that exercised removed migrations should be deleted with them; do not keep a test that asserts nothing.
|
||||
|
||||
- [ ] **Step 5: Verify `update` still runs end to end**
|
||||
|
||||
```bash
|
||||
bun run --cwd examples/basic-app wrnexus update --dry-run
|
||||
```
|
||||
|
||||
Expected: completes without error and reports no pending migrations for an app already at the current version. Paste the output into the commit body if it is short.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "chore: drop update migrations below 0.8.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove the deprecated compiler re-export shims
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/compiler/src/codegen.ts` (~line 20, the `./parser.ts` import)
|
||||
- Modify: `packages/compiler/src/native-codegen.ts` (~line 1, the `./parser.ts` import)
|
||||
- Delete: `packages/compiler/src/parser.ts`, `packages/compiler/src/tokenizer.ts`, `packages/compiler/src/types.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces: nothing new; imports move to `@wrnexus/syntax`.
|
||||
|
||||
**Order matters.** These three files are two-line re-exports marked deprecated, but `codegen.ts` and `native-codegen.ts` still import from them. Deleting the files first breaks the build.
|
||||
|
||||
- [ ] **Step 1: Repoint the imports**
|
||||
|
||||
In `packages/compiler/src/codegen.ts`, change:
|
||||
|
||||
```ts
|
||||
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
||||
```
|
||||
|
||||
to import the same names from `@wrnexus/syntax`. If the file already imports from `@wrnexus/syntax`, merge them into that one import rather than adding a second.
|
||||
|
||||
In `packages/compiler/src/native-codegen.ts`, change:
|
||||
|
||||
```ts
|
||||
import type { Attr, PageAst, ViewNode } from "./parser.ts";
|
||||
```
|
||||
|
||||
the same way.
|
||||
|
||||
- [ ] **Step 2: Verify nothing else imports the shims**
|
||||
|
||||
```bash
|
||||
grep -rn "from \"./parser.ts\"\|from \"./tokenizer.ts\"\|from \"./types.ts\"" packages/compiler/src/
|
||||
```
|
||||
|
||||
Expected: no output. If anything remains, repoint it before continuing.
|
||||
|
||||
- [ ] **Step 3: Delete the shims**
|
||||
|
||||
```bash
|
||||
git rm packages/compiler/src/parser.ts packages/compiler/src/tokenizer.ts packages/compiler/src/types.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/compiler && bun run typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Rebuild the editor bundles and commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun run --cwd editors/vscode build
|
||||
git add -A packages/compiler editors/vscode/src
|
||||
git commit -m "refactor: drop the deprecated compiler re-export shims"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Remove the deprecated `@wrnexus/auth` options
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/auth/src/http/index.ts` (~lines 56-59)
|
||||
- Modify: `packages/auth/src/plugin.ts` (~lines 55-58)
|
||||
- Modify: `packages/auth/src/types.ts` (~line 423)
|
||||
- Modify: `packages/auth/src/engine.ts` (~lines 163-165 and 179-181)
|
||||
- Modify: `packages/auth/test/http.test.ts`, `packages/auth/test/plugin.test.ts`, `packages/auth/test/engine.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces: nothing new. Options are removed, not renamed.
|
||||
|
||||
**These are our own superseded options, not an out-of-date dependency.** The current form is already what `examples/auth-showcase/app/lib/auth.ts` uses — it passes `onSignedIn` / `onSignedOut` to `createAuthEngine`, which is correct and must not change. The deprecated members are the same names on _different_ option objects.
|
||||
|
||||
- [ ] **Step 1: Confirm the blast radius before deleting**
|
||||
|
||||
```bash
|
||||
grep -rn "onSuccessfullSignUp" packages/ examples/ services/ | grep -v dist/
|
||||
grep -rn "onSignedIn\|onSignedOut" packages/ examples/ --include=*.ts | grep -v "packages/auth/src" | grep -v dist/
|
||||
```
|
||||
|
||||
Expected: `onSuccessfullSignUp` has zero references. The `onSignedIn` / `onSignedOut` hits are `packages/auth/test/http.test.ts`, `packages/auth/test/plugin.test.ts`, and `examples/auth-showcase/app/lib/auth.ts`. **The example is the correct `createAuthEngine` form and must be left alone.** Record what you found; if the results differ from this, stop and report before deleting anything.
|
||||
|
||||
- [ ] **Step 2: Remove the option declarations**
|
||||
|
||||
Delete `onSignedIn` and `onSignedOut` (and their `@deprecated` comments) from the options interface in `packages/auth/src/http/index.ts` and from `packages/auth/src/plugin.ts`. Delete `onSuccessfullSignUp` from `packages/auth/src/types.ts`. Delete the `rpId` and `origin` members from both verification signatures in `packages/auth/src/engine.ts`.
|
||||
|
||||
Then remove the code that reads them. The `rpId` / `origin` values are already ignored — verification uses the values bound to the issued challenge — so removing them changes no behaviour.
|
||||
|
||||
- [ ] **Step 3: Update the tests that exercised the deprecated paths**
|
||||
|
||||
`packages/auth/test/http.test.ts` and `plugin.test.ts` pass the deprecated options. Rewrite each to use the `createAuthEngine` form where the test is still meaningful, and delete the test where its only purpose was to cover the deprecated alias.
|
||||
|
||||
`packages/auth/test/engine.test.ts` passes `rpId` / `origin` to verification. Remove those arguments; the assertions on the verification result should be unchanged, which is the evidence that the options were inert.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/auth && bun run typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Confirm no `@deprecated` markers remain in auth**
|
||||
|
||||
```bash
|
||||
grep -rn "@deprecated" packages/auth/src/
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add -A packages/auth
|
||||
git commit -m "refactor: remove the deprecated auth options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Full gate
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: whatever the gate reports as stale (generated types, public API baseline, editor bundles)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Tasks 1-5.
|
||||
- Produces: a green `check:production`.
|
||||
|
||||
- [ ] **Step 1: Rebuild the editor bundles**
|
||||
|
||||
```bash
|
||||
bun run --cwd editors/vscode build
|
||||
```
|
||||
|
||||
The compiler and language server are embedded there and both changed.
|
||||
|
||||
- [ ] **Step 2: Run the full gate**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun test
|
||||
bun run typecheck
|
||||
bun run check:production
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Regenerate anything the gate reports as stale**
|
||||
|
||||
`check:public-api` fails when exports change — and this plan removed several from `@wrnexus/styles`. Run `bun run generate:public-api`, then **read the diff and confirm it is removals only**. An unexpected addition means something was exported by accident.
|
||||
|
||||
`check:generated-types` may need `bun run --cwd examples/basic-app wrnexus generate types`.
|
||||
|
||||
- [ ] **Step 4: Re-run the gate until green**
|
||||
|
||||
```bash
|
||||
bun run check:production
|
||||
```
|
||||
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: regenerate baselines after the legacy cleanup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **The seven config keys are dead.** If you find code that actually reads one, stop and report — the spec's central claim would be wrong and the plan needs revisiting.
|
||||
- **Task 1 is the only behaviour-sensitive change.** Its first two tests describe current behaviour and must pass both before and after. If they fail after, the substitution was not equivalent; report rather than editing the test.
|
||||
- **The auth example is already correct.** `examples/auth-showcase` uses `createAuthEngine({ onSignedIn })`, which is the current API, not the deprecated one.
|
||||
- **If a test would still pass with the code it guards deleted, it is not a test.** Delete the implementation, watch it fail, restore it.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,496 @@
|
||||
# `wrnexus update` Migration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** One `wrnexus update` carries an existing project from today's syntax to the syntax left by the cleanup and `apis { }` plans — or refuses precisely, naming the file and the reason.
|
||||
|
||||
**Architecture:** These are new `Migration` entries in the existing `update.ts` framework, which already has dry-run support and a report that separates automatic changes from ones needing review. `.wrn` rewriting parses with `@wrnexus/syntax` and re-emits through `formatWrn`, both already imported there.
|
||||
|
||||
**Tech Stack:** Bun, TypeScript, `bun:test`, `@wrnexus/syntax`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-19-update-migration-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **A file is transformed correctly, or it is left untouched and reported.** There is no third outcome — never a partial rewrite.
|
||||
- Every migration is **idempotent**: running it twice changes nothing the second time.
|
||||
- **Dry-run reports exactly what a real run would change**, and writes nothing.
|
||||
- A run with anything in `needsReview` or `parseFailures` **exits non-zero**, so a scripted upgrade cannot appear to succeed while leaving a project half-migrated.
|
||||
- Migrations attach to the release that ships the breaking change, above the `0.8.0` floor.
|
||||
- `bun run format` before every commit; the gate is `bun run check:production`.
|
||||
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Remove the dead config keys
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/cli/src/update.ts` (add a `Migration`)
|
||||
- Test: `packages/cli/test/migrate-config-keys.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: a migration with `id: "remove-dead-config-keys"`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/migrate-config-keys.test.ts`:
|
||||
|
||||
```ts
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runMigrations } from "../src/update.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const CONFIG = `export default {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
compatibility: { legacyEmit: false, stringLayouts: false },
|
||||
observability: { sampleRate: 1 },
|
||||
};
|
||||
`;
|
||||
|
||||
function project(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app"), { recursive: true });
|
||||
writeFileSync(join(root, "wrnexus.config.ts"), CONFIG);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("the removed keys are deleted and the rest is kept", async () => {
|
||||
const root = project();
|
||||
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false });
|
||||
const config = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
|
||||
expect(config).not.toContain("compatibilityDate");
|
||||
expect(config).not.toContain("frameworkBehaviour");
|
||||
expect(config).not.toContain("legacyDefaultRuntime");
|
||||
expect(config).not.toContain("legacyEmit");
|
||||
expect(config).toContain("observability");
|
||||
});
|
||||
|
||||
test("running it twice changes nothing the second time", async () => {
|
||||
const root = project();
|
||||
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false });
|
||||
const once = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false });
|
||||
|
||||
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(once);
|
||||
});
|
||||
|
||||
test("a dry run writes nothing", async () => {
|
||||
const root = project();
|
||||
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: true });
|
||||
|
||||
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(CONFIG);
|
||||
});
|
||||
```
|
||||
|
||||
Use whatever entry point `update.ts` exports for running migrations; if the name differs from `runMigrations`, adapt the calls and keep the assertions identical.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/migrate-config-keys.test.ts`
|
||||
Expected: FAIL — the keys survive.
|
||||
|
||||
- [ ] **Step 3: Add the migration**
|
||||
|
||||
Append to the migration list in `packages/cli/src/update.ts`:
|
||||
|
||||
```ts
|
||||
{
|
||||
version: "0.9.0",
|
||||
id: "remove-dead-config-keys",
|
||||
description: "Delete compatibilityDate, frameworkBehaviour, functions, and compatibility",
|
||||
apply(ctx) {
|
||||
const file = join(ctx.appRoot, "wrnexus.config.ts");
|
||||
if (!existsSync(file)) return;
|
||||
|
||||
const before = readFileSync(file, "utf8");
|
||||
// Each key is a whole property line or block; removing the line leaves
|
||||
// valid TypeScript because these are always object members.
|
||||
const after = before
|
||||
.replace(/^\s*compatibilityDate:.*\n/m, "")
|
||||
.replace(/^\s*frameworkBehaviour:.*\n/m, "")
|
||||
.replace(/^\s*functions:\s*\{[^}]*\},?\s*\n/m, "")
|
||||
.replace(/^\s*compatibility:\s*\{[^}]*\},?\s*\n/m, "");
|
||||
|
||||
if (after === before) return;
|
||||
|
||||
ctx.report.changedAutomatically.push(`${file}: removed dead compatibility keys`);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "feat(cli): migrate away the dead config keys"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Move `ssr { api … }` / `client { api … }` into `apis { }`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/migrations/apis-block.ts`
|
||||
- Modify: `packages/cli/src/update.ts` (register the migration)
|
||||
- Test: `packages/cli/test/migrate-apis-block.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `migrateApisBlock(source: string): { source: string; changed: boolean } | { skip: string }` — a pure function over `.wrn` text, so it is testable without a filesystem. `skip` carries the human-readable reason.
|
||||
|
||||
Sectioned bodies carry across unchanged, because the payload is already bound to `data`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/migrate-apis-block.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateApisBlock } from "../src/migrations/apis-block.ts";
|
||||
|
||||
const SOURCE = `page Search {
|
||||
client {
|
||||
api searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
error { return [] }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a client api entry moves into an apis block", () => {
|
||||
const result = migrateApisBlock(SOURCE) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain("apis {");
|
||||
expect(result.source).toContain("searchUsers POST /api/users");
|
||||
expect(result.source).not.toContain("client {\n api");
|
||||
});
|
||||
|
||||
test("the sections survive unchanged", () => {
|
||||
const result = migrateApisBlock(SOURCE) as { source: string };
|
||||
|
||||
expect(result.source).toContain("return data.users");
|
||||
expect(result.source).toContain("return []");
|
||||
});
|
||||
|
||||
test("running it on migrated source changes nothing", () => {
|
||||
const once = (migrateApisBlock(SOURCE) as { source: string }).source;
|
||||
const twice = migrateApisBlock(once) as { source: string; changed: boolean };
|
||||
|
||||
expect(twice.changed).toBe(false);
|
||||
expect(twice.source).toBe(once);
|
||||
});
|
||||
|
||||
test("a name declared in both modes is skipped with a reason", () => {
|
||||
const clash = `page P {
|
||||
ssr { api dup GET /api/a { response { return data } } }
|
||||
client { api dup GET /api/a { response { return data } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(clash) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("dup");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/migrate-apis-block.test.ts`
|
||||
Expected: FAIL — the module does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the transform**
|
||||
|
||||
Create `packages/cli/src/migrations/apis-block.ts`. Parse with `parse` from `@wrnexus/syntax` to find the entries and validate the file, collect every `api` entry from `ssr` / `client` blocks, detect duplicate names across modes and return `{ skip }` when found, then emit one `apis { }` block and delete the now-empty mode blocks. Re-emit through `formatWrn`.
|
||||
|
||||
Detect already-migrated input by checking whether the source has an `apis` block and no mode data blocks; return `{ source, changed: false }`.
|
||||
|
||||
- [ ] **Step 4: Register it**
|
||||
|
||||
Add a `Migration` with `id: "move-api-blocks"` that walks `app/**/*.wrn`, calls `migrateApisBlock`, and routes the outcome: a change goes to `changedAutomatically`, a `skip` goes to `needsReview` with the file and reason, and a `parse` failure goes to `parseFailures` with the file left untouched.
|
||||
|
||||
- [ ] **Step 5: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "feat(cli): migrate api entries into the apis block"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Move mode-scoped helpers into `functions { shared … }`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/migrations/mode-functions.ts`
|
||||
- Modify: `packages/cli/src/update.ts`
|
||||
- Test: `packages/cli/test/migrate-mode-functions.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `migrateModeFunctions(source: string): { source: string; changed: boolean } | { skip: string }`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/migrate-mode-functions.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateModeFunctions } from "../src/migrations/mode-functions.ts";
|
||||
|
||||
const SOURCE = `page Hello {
|
||||
ssr {
|
||||
functions {
|
||||
function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a mode helper becomes a shared function", () => {
|
||||
const result = migrateModeFunctions(SOURCE) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain("shared function userNames");
|
||||
expect(result.source).not.toContain("ssr {");
|
||||
});
|
||||
|
||||
test("running it again changes nothing", () => {
|
||||
const once = (migrateModeFunctions(SOURCE) as { source: string }).source;
|
||||
const twice = migrateModeFunctions(once) as { changed: boolean; source: string };
|
||||
|
||||
expect(twice.changed).toBe(false);
|
||||
expect(twice.source).toBe(once);
|
||||
});
|
||||
|
||||
test("a name that already exists at page level is skipped with a reason", () => {
|
||||
const clash = `page P {
|
||||
functions { shared function userNames() { return "" } }
|
||||
ssr { functions { function userNames(users) { return "" } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateModeFunctions(clash) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("userNames");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/migrate-mode-functions.test.ts`
|
||||
Expected: FAIL — the module does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement and register**
|
||||
|
||||
Create the module following Task 2's shape: relocate each mode-scoped function into the page-level `functions { }` with the `shared` modifier, skipping the file with a reason when a name already exists there. Register a `Migration` with `id: "move-mode-functions"` that routes outcomes to the same three report buckets.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "feat(cli): migrate mode-scoped helpers to shared functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Detect legacy bare-body blocks and report them — do not rewrite
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/migrations/legacy-api-body.ts`
|
||||
- Modify: `packages/cli/src/update.ts`
|
||||
- Test: `packages/cli/test/migrate-legacy-api-body.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `detectLegacyApiBodies(source: string): { name: string; freeIdentifiers: string[] }[]`.
|
||||
|
||||
**This transform is deliberately manual, and the test pins that.** A legacy bare body is evaluated inside `with ($data ?? {})`, so it references payload fields as bare identifiers. Converting `return userNames(users)` needs `data.users` — but **nothing in the source distinguishes `users` (payload) from `userNames` (page helper)**. The response shape belongs to the route, which may not be typed. A migration that guessed would emit code that compiles and is wrong.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/migrate-legacy-api-body.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { detectLegacyApiBodies } from "../src/migrations/legacy-api-body.ts";
|
||||
|
||||
const SOURCE = `page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return userNames(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a legacy bare body is detected with its free identifiers", () => {
|
||||
const found = detectLegacyApiBodies(SOURCE);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.name).toBe("ssrUsers");
|
||||
expect(found[0]!.freeIdentifiers).toContain("users");
|
||||
expect(found[0]!.freeIdentifiers).toContain("userNames");
|
||||
});
|
||||
|
||||
test("a sectioned block is not reported", () => {
|
||||
const sectioned = `page P {
|
||||
apis { x GET /api/x { response { return data.users } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(detectLegacyApiBodies(sectioned)).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/migrate-legacy-api-body.test.ts`
|
||||
Expected: FAIL — the module does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement detection only**
|
||||
|
||||
Create the module. Find `api` entries whose `sections` is absent (the bare-body form), and collect the free identifiers in the body — identifiers that are not declared locally and are not JavaScript globals. Return them. **Write no transform.**
|
||||
|
||||
- [ ] **Step 4: Register a report-only migration**
|
||||
|
||||
Add a `Migration` with `id: "report-legacy-api-bodies"` that pushes one `needsReview` entry per block, naming the file, the block, and the identifiers, and leaves the file byte-identical. Have `wrnexus update` print a short line explaining why this one is manual: the payload fields cannot be told apart from page helpers without knowing the route's response shape.
|
||||
|
||||
- [ ] **Step 5: Write the byte-identical test**
|
||||
|
||||
Add a test that runs the full migration over a fixture project containing a legacy bare body and asserts the file's contents are unchanged afterwards, and that the report names the block. **This is the most important test in the plan** — it pins that the migration does not attempt the rewrite.
|
||||
|
||||
- [ ] **Step 6: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "feat(cli): report legacy api bodies for manual migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Exit code, output order, and the end-to-end run
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/cli/src/update.ts` (the command's output and exit code)
|
||||
- Test: `packages/cli/test/update-exit-code.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Tasks 1-4.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/update-exit-code.test.ts` asserting that a project with a legacy bare body produces a non-zero exit, and a fully-migratable project produces zero. Use the same temp-project pattern as Task 1.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/update-exit-code.test.ts`
|
||||
Expected: FAIL — the command currently exits zero regardless.
|
||||
|
||||
- [ ] **Step 3: Implement the output and exit code**
|
||||
|
||||
Print, in order: what changed, what needs review and why, what failed to parse. Exit non-zero when `needsReview` or `parseFailures` is non-empty.
|
||||
|
||||
- [ ] **Step 4: Migrate the example app with the command alone**
|
||||
|
||||
```bash
|
||||
bun run --cwd examples/basic-app wrnexus update
|
||||
```
|
||||
|
||||
Expected: the `.wrn` pages are migrated by the tool, not by hand. **If the framework's own example cannot be migrated by the tool, the tool is not finished** — report that rather than editing the example manually.
|
||||
|
||||
- [ ] **Step 5: Verify the migrated example**
|
||||
|
||||
```bash
|
||||
bun run --cwd examples/basic-app build
|
||||
bun test
|
||||
bun run typecheck
|
||||
bun run check:production
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add -A
|
||||
git commit -m "feat(cli): fail the update when a project needs manual review"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **Never half-rewrite a file.** Parse first; on failure, record and move on. If any part of a file's transform cannot complete, skip the whole file and report it.
|
||||
- **Idempotency is not optional.** Every transform detects already-migrated input.
|
||||
- **Task 4 writes no transform.** If you find yourself building one, stop — the spec explains why a correct automatic answer does not exist.
|
||||
|
||||
---
|
||||
|
||||
## Post-execution note (2026-08-20)
|
||||
|
||||
**Step 4 of Task 5 did not prove what it was written to prove.** By the time it ran,
|
||||
`examples/basic-app` had already been moved to the current syntax by hand in commit
|
||||
`890d6106`, so `wrnexus update` migrated a no-op target: 0 changed, 0 needing review,
|
||||
0 parse failures. The framework's own example therefore does NOT demonstrate the tool
|
||||
against real legacy syntax.
|
||||
|
||||
The guarantee instead rests on fixture-based tests that drive the real `updateApp` /
|
||||
`runUpdate` entry points over projects containing genuine `ssr {}` / `client {}` source
|
||||
-- including the mixed-content page that only converges because `move-mode-functions`
|
||||
is registered before `move-api-blocks`. That is adequate coverage, but it is a weaker
|
||||
kind of evidence than the plan intended, and it is recorded here rather than quietly
|
||||
counted as a pass.
|
||||
@@ -0,0 +1,250 @@
|
||||
# The `apis { }` block and location-transparent dispatch — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** One container block for API declarations, callable from anywhere as `api.<name>(input)`,
|
||||
dispatched in-process on the server and over `fetch` in the browser.
|
||||
|
||||
**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md`. That spec removes the
|
||||
compatibility surface and the `"legacy"` function runtime; this one removes the legacy `api` forms
|
||||
by replacing them.
|
||||
|
||||
## Goal
|
||||
|
||||
Today a `.wrn` page has several unrelated ways to reach data: an `api` block inside `ssr {}`, an
|
||||
`api` block inside `client {}`, a `server function` over RPC, a `load server` block, and hand-written
|
||||
`fetch`. Each has different placement, different capabilities, and a different call shape. The
|
||||
result is that "how do I fetch this?" has no single answer, and the answer that is right depends on
|
||||
where the code happens to sit.
|
||||
|
||||
This collapses the API half of that into one declaration and one call, and draws a line a developer
|
||||
can hold in their head:
|
||||
|
||||
- **`api.<name>()`** — call an API route that exists as a real HTTP endpoint.
|
||||
- **`server.<name>()`** — call server-side logic that has no public surface.
|
||||
|
||||
The question becomes "is there a route?", not "where am I running?". `server.<name>()` is unchanged
|
||||
by this spec.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Changing `server function`, actions, or `load` blocks. They keep working exactly as they do.
|
||||
- External or third-party API targets. Still this app's `/api/*` routes only, preserving
|
||||
`isSafeApiPath`.
|
||||
- Any new configuration key. This spec adds none.
|
||||
- Author-settable request headers, still excluded.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
| ---------------------- | ---------------------------------------------------------------------- |
|
||||
| Container | Page-level `apis { }`, matching `functions { }` |
|
||||
| Declaration | Mode-less — no `ssr` / `client` prefix |
|
||||
| Call | `api.<name>(input)` from any context |
|
||||
| Dispatch | Chosen at build time: in-process on the server, `fetch` in the browser |
|
||||
| Server request context | `AsyncLocalStorage` |
|
||||
| Browser emission | Only blocks the client actually calls |
|
||||
| Render binding | `api="name"`, `api="name()"`, `api="name({ … })"` |
|
||||
| Old forms | Removed and replaced |
|
||||
|
||||
## Syntax
|
||||
|
||||
```wrn
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
age?: number
|
||||
}
|
||||
}
|
||||
|
||||
response { return data.data.users }
|
||||
error { return [] }
|
||||
}
|
||||
|
||||
listTeams GET /api/teams {
|
||||
response { return data.data.teams }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`GET` and `HEAD` declare `parameters`, which become a query string; other methods declare `body`,
|
||||
sent as JSON. The path stays a plain literal so `isSafeApiPath` is satisfied without relaxing it.
|
||||
|
||||
Names are unique per page — `codegen.ts` already rejects duplicates, and that stays.
|
||||
|
||||
### Why the container
|
||||
|
||||
`functions { }` puts the modifier first inside a container named for the concept:
|
||||
`functions { client function x() }`. Today's api form inverts that — `client { api x … }` — and is
|
||||
the only construct in the language shaped that way, which is why APIs are hard to find in a page.
|
||||
`apis` (plural) is the container; `api` remains the call namespace and the binding attribute.
|
||||
|
||||
## Calling
|
||||
|
||||
```wrn
|
||||
functions {
|
||||
client async function search(): Promise<void> {
|
||||
users = await api.searchUsers({ name: nameFilter })
|
||||
}
|
||||
}
|
||||
|
||||
load server directory {
|
||||
return await api.searchUsers({ name: ctx.url.searchParams.get("name") ?? "" })
|
||||
}
|
||||
```
|
||||
|
||||
The same call works in client functions, `load` blocks, actions, and server functions. Nothing at
|
||||
the call site says where it runs.
|
||||
|
||||
### Dispatch
|
||||
|
||||
Dispatch is decided at build time, not by a runtime check. The compiler emits an `api` object into
|
||||
each execution context, with the same member names and different transports behind them:
|
||||
|
||||
- **Browser** — the existing `callApi` transport: query string or JSON body, `credentials:
|
||||
"same-origin"`, `x-csrf-token` on non-GET, the established failure contract.
|
||||
- **Server** — `callApiFromContext`, which dispatches to the route in-process. No network hop, no
|
||||
serialisation round trip, and the caller's cookies, session, and locals are already forwarded.
|
||||
|
||||
Two objects that never meet, so nothing ships to the browser that only the server uses, and the
|
||||
runtime never branches on `typeof window` for something known at compile time.
|
||||
|
||||
### `callApiFromContext` must learn to carry input
|
||||
|
||||
It currently builds `new Request(apiUrl, { method, headers })` — no body, no query string. It has to
|
||||
assemble the request the same way the browser transport does, from the same rules, or the two sides
|
||||
will disagree about what an identical call sends. **The assembly rules must be shared, not
|
||||
reimplemented**: a second copy will drift, and the drift will be silent because each side is tested
|
||||
separately.
|
||||
|
||||
### The request context
|
||||
|
||||
A server-side call needs `ctx` to resolve the URL and forward cookies and session. `ctx` is not
|
||||
uniformly available: `load` blocks have it, schema actions have it as a second parameter, plain
|
||||
actions and server functions have neither.
|
||||
|
||||
The server stores the request context in an `AsyncLocalStorage` at request entry, and the server
|
||||
`api` object reads it. This is new machinery — the framework uses none today — and it must be
|
||||
established in both the dev server and the production server, or a call that works in development
|
||||
fails in production.
|
||||
|
||||
When no context is present, the call throws with a message naming the block and explaining that an
|
||||
API call needs a request context — never a silent `undefined`.
|
||||
|
||||
## Emission
|
||||
|
||||
A block's `response` and `error` bodies are page code. They ship to the browser **only when a
|
||||
client-side call to that block exists**. The compiler already knows which `api.<name>()` calls
|
||||
appear in client functions.
|
||||
|
||||
This keeps server-only transforms off the wire and the client bundle proportional to what it uses.
|
||||
The consequence to know: adding the first client call to a block starts shipping that block's
|
||||
bodies. Anything secret belongs in the endpoint, not in a `response` body.
|
||||
|
||||
## Render binding
|
||||
|
||||
A block can be bound into markup, which calls it during render and substitutes the result:
|
||||
|
||||
```wrn
|
||||
<p api="listTeams">loading…</p>
|
||||
<ul api="searchUsers({ name: nameFilter })">
|
||||
{#each searchUsers as person}<li>{person.name}</li>{/each}
|
||||
</ul>
|
||||
```
|
||||
|
||||
Three accepted forms: `api="name"`, `api="name()"` — equivalent — and `api="name({ … })"`, which
|
||||
passes arguments. The argument expression is evaluated in the same scope as other view expressions
|
||||
at render time. This mirrors `@click="search()"`, so it introduces no new escaping or naming rules.
|
||||
|
||||
### The edge this creates, stated plainly
|
||||
|
||||
A block that is both render-bound and called from code **runs twice** — once for the binding, once
|
||||
for the call. They are two different lifecycles wearing one name, and no deduplication is attempted:
|
||||
a render-time fetch and a user-triggered fetch are usually meant to be different requests, and
|
||||
silently collapsing them would be worse than the duplication. Authors binding a block _and_ calling
|
||||
it should expect two requests.
|
||||
|
||||
## Replacing the old forms
|
||||
|
||||
`ssr { … }` and `client { … }` data blocks are removed. Each could contain only two things, and both
|
||||
have a home:
|
||||
|
||||
| Old | New |
|
||||
| ------------------------------------------------ | ------------------------------------------------------ |
|
||||
| `ssr { api x … }` / `client { api x … }` | `apis { x … }` |
|
||||
| `ssr { functions { function helper() } }` | `functions { shared function helper() }` |
|
||||
| legacy bare-body `api x GET /p { return users }` | `apis { x GET /p { response { return data.users } } }` |
|
||||
|
||||
The legacy bare body injected the payload with `with ($data ?? {})`, which is **untypeable** —
|
||||
TypeScript cannot see through `with`, and that is the entire reason sectioned blocks bind a named
|
||||
`data`. Removing the legacy form removes that fork: one payload binding, typed.
|
||||
|
||||
`client state { }` is a different construct that shares the keyword and is **not** affected.
|
||||
|
||||
`examples/basic-app/app/pages/hello.wrn` uses both old forms and is the migration's worked example.
|
||||
|
||||
## Type safety
|
||||
|
||||
Assertions are generated into `app/types/wrnexus.generated.api-checks.ts` and checked by the
|
||||
project's own `tsc`, as established. Two changes follow from mode-less declarations:
|
||||
|
||||
- The current generator skips blocks whose `mode !== "client"`. That skip exists because an `ssr`
|
||||
block could never declare a `request`. Mode-less blocks invalidate the reasoning, so **every block
|
||||
with declared fields gets an assertion**.
|
||||
- The zero-field skip stays: a block with no declared fields has nothing to check, and asserting
|
||||
`Record<string, never>` against a contract fails spuriously.
|
||||
|
||||
## Known edges
|
||||
|
||||
- **`state api` stops working.** The name is currently excluded from destructuring only when a page
|
||||
has client api blocks, so pages without them keep using it. Once `api` is universal that
|
||||
protection goes, and a page with `state api` breaks. It is a build-time failure, not silent.
|
||||
- **Every block is browser-reachable in principle.** The routes were already publicly reachable by
|
||||
`fetch`, so this exposes no new surface — but a block is no longer implicitly server-only by
|
||||
virtue of its placement.
|
||||
|
||||
## Testing
|
||||
|
||||
**Parser**
|
||||
|
||||
- `apis { }` parses multiple mode-less entries; duplicate names are rejected.
|
||||
- `ssr { api … }` and `client { api … }` are rejected with a message naming the replacement.
|
||||
- All three binding forms parse, including an argument expression containing a nested object.
|
||||
|
||||
**Dispatch**
|
||||
|
||||
- A client-side call issues one `fetch` with the expected URL, method, body, and CSRF header.
|
||||
- A server-side call dispatches in-process and issues **no** network request — asserted by observing
|
||||
that no fetch occurs, not merely that the result is right.
|
||||
- A server-side call with no request context throws a message naming the block.
|
||||
- The same declared input produces the same request on both sides — the shared-assembly guard.
|
||||
|
||||
**Emission**
|
||||
|
||||
- A block called only from server code does not appear in the browser module.
|
||||
- A block called from a client function does.
|
||||
|
||||
**Render binding**
|
||||
|
||||
- Each of the three forms renders the resolved value.
|
||||
- A bound block that is also called issues two requests, pinning the documented edge.
|
||||
|
||||
**Type safety**
|
||||
|
||||
- A block declaring a field its endpoint rejects fails `bun run typecheck`, asserted by running
|
||||
`tsc` and reading its diagnostics — not by matching generated text.
|
||||
|
||||
**End to end**
|
||||
|
||||
- `examples/basic-app` migrated to `apis { }`, driven in a browser: a client call updates state, a
|
||||
render-bound block appears in the served HTML, and a deliberately failing call takes the `error`
|
||||
path.
|
||||
|
||||
## Deferred
|
||||
|
||||
- External API targets, and the allowlist and credential handling they need.
|
||||
- Author-settable headers.
|
||||
- Deduplicating a render-bound block against a code call.
|
||||
- Response caching and request de-duplication.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Editor support for the new syntax — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** Language server and VS Code extension updated for `apis { }`, the `api.<name>()` call, and
|
||||
the removal of the mode data blocks.
|
||||
|
||||
**Depends on:** `2026-08-19-apis-block-design.md`. The syntax must exist before the editor can
|
||||
describe it.
|
||||
|
||||
## Goal
|
||||
|
||||
A syntax change that the editor does not know about is worse than no change: valid code is
|
||||
red-underlined, removed constructs still autocomplete, and the new block gets no highlighting. This
|
||||
spec keeps the tooling level with the language.
|
||||
|
||||
It also settles a question left open by earlier work: whether type errors from the generated
|
||||
assertions actually appear inside the `.wrn` file, or only in the generated file. That was never
|
||||
verified — it was inferred from reading source — and inference is not good enough for the thing
|
||||
developers rely on to tell them their code is wrong.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- New editor features unrelated to this syntax change.
|
||||
- Editors other than VS Code beyond what standard LSP provides.
|
||||
- HTML formatting — `formatWrn` still owns markup.
|
||||
|
||||
## What exists
|
||||
|
||||
- **Grammar:** `editors/vscode/syntaxes/wrn.tmLanguage.json` names block keywords directly —
|
||||
`api` appears 4 times, `client` 6, `server` 5, `ssr` once.
|
||||
- **Keyword list:** `WRN_KEYWORDS` in `packages/language-server/src/index.ts` drives completion and
|
||||
includes `api` but not `apis`.
|
||||
- **Extension completions:** `editors/vscode/src/completion.js` carries block snippets.
|
||||
- **Server features:** completion, hover, folding, linked editing, tag completion, and TypeScript
|
||||
diagnostics over a virtual document.
|
||||
|
||||
## Changes by surface
|
||||
|
||||
### Grammar
|
||||
|
||||
Add `apis` as a block keyword. Remove the `ssr` / `client` **data block** patterns, keeping `client`
|
||||
and `server` where they mean other things — `client state { }`, the `runtime` values, and the
|
||||
function modifiers in `functions { }`. This is the change most likely to over-reach: `client` is one
|
||||
word with several jobs in this language, and blanket removal would un-highlight constructs that
|
||||
still exist.
|
||||
|
||||
Highlight an `apis` entry's shape — name, method, path — so a declaration reads as a declaration
|
||||
rather than as loose identifiers.
|
||||
|
||||
### Keyword and block completion
|
||||
|
||||
- `apis` joins `WRN_KEYWORDS`.
|
||||
- A snippet for the container and a snippet for an entry, including the `request` / `response` /
|
||||
`error` sections, so the shape is discoverable without the docs.
|
||||
- `ssr` and `client` data-block snippets are removed from `completion.js`. Offering a construct the
|
||||
compiler rejects is worse than offering nothing.
|
||||
|
||||
### Call completion — the feature worth building
|
||||
|
||||
Inside a function body, `api.` should complete to the names declared in that page's `apis { }`
|
||||
block, with the method and path as detail. The server already indexes the document to build
|
||||
completions, and the block names are in the AST.
|
||||
|
||||
This is where the syntax pays off in the editor: the set of legal calls is knowable, so the editor
|
||||
should know it. Without it, `api.` is an empty namespace and every call is typed from memory.
|
||||
|
||||
Hovering a name inside `api.<name>()` shows its method, path, and declared request fields.
|
||||
|
||||
### The `api=` attribute in markup
|
||||
|
||||
`api="searchUsers({ name: nameFilter })"` is an attribute whose value is a call expression. The HTML
|
||||
service must not flag it as an unknown attribute, and the expression must not be treated as plain
|
||||
text. Completion inside the quotes offers the page's block names, matching the `api.` behaviour.
|
||||
|
||||
### Diagnostics for removed constructs
|
||||
|
||||
An `ssr { api … }` or `client { api … }` block gets a diagnostic naming `apis { }` as the
|
||||
replacement, positioned on the block keyword. The compiler already rejects these; the editor should
|
||||
say so while typing rather than at build time, and it should say what to do instead.
|
||||
|
||||
## Inline type errors — verifying, not assuming
|
||||
|
||||
The `apis` spec generates assertions into `app/types/wrnexus.generated.api-checks.ts`, and `tsc`
|
||||
fails when a block declares a field its endpoint rejects. Whether that failure surfaces **inside the
|
||||
`.wrn` file** has never been confirmed.
|
||||
|
||||
This spec resolves it in two steps, in order:
|
||||
|
||||
1. **Observe the current behaviour.** With a deliberately wrong field in place, open the page in VS
|
||||
Code and record where the error appears: on the block, only in the generated file, or nowhere.
|
||||
2. **Act on what is observed.** If the error already surfaces usefully, document it and stop. If it
|
||||
appears only in the generated file, map the diagnostic back to the block that produced it — the
|
||||
generator knows which page and block each assertion came from, so the mapping is available if it
|
||||
is recorded rather than discarded.
|
||||
|
||||
If step 2 proves larger than this spec can hold, it becomes its own work, and the spec says so
|
||||
plainly rather than leaving an unfinished feature implied. **Nothing here should claim inline
|
||||
diagnostics work until someone has seen them work.**
|
||||
|
||||
## Testing
|
||||
|
||||
**Grammar** — a fixture page using `apis { }`, `client state { }`, `functions { shared function }`,
|
||||
and `runtime = "client"` tokenizes correctly; `client` keeps its highlighting everywhere it is still
|
||||
valid. This is the guard against over-reaching removal.
|
||||
|
||||
**Completion**
|
||||
|
||||
- `apis` is offered at page level; `ssr` / `client` data blocks are not.
|
||||
- `api.` inside a function body offers the page's declared names with method and path.
|
||||
- Inside `api="…"` in markup, the same names are offered.
|
||||
- Outside those contexts, completion is unchanged — the guard that non-API editing is undisturbed.
|
||||
|
||||
**Hover** — a name inside `api.<name>()` reports its method, path, and request fields.
|
||||
|
||||
**Diagnostics** — an `ssr { api … }` block produces a diagnostic naming `apis { }`, positioned on
|
||||
the block keyword.
|
||||
|
||||
**Bundles** — `check:editor-compiler`, `check:editor-language-server`, and
|
||||
`check:editor-extension` pass. These embed the compiler and language server, so they must be rebuilt
|
||||
after the syntax change; a stale bundle fails the gate.
|
||||
|
||||
**Manual, and recorded in the implementation notes** — open the migrated `examples/basic-app` in VS
|
||||
Code: `apis { }` highlights, `api.` completes, a removed construct is flagged, and the inline
|
||||
type-error question above is answered by observation.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Mapping generated assertion diagnostics back into `.wrn`, if step 2 above proves too large.
|
||||
- Moving the remaining component intelligence out of `completion.js` and into the server.
|
||||
- Editors other than VS Code.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Legacy, deprecated, and unused-config cleanup — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** Remove the compatibility-flag surface, the `"legacy"` function runtime, dead migrations,
|
||||
and deprecated APIs — before the framework's first public release.
|
||||
|
||||
## Goal
|
||||
|
||||
WRNexus carries compatibility machinery for a public it does not yet have. Every branch of it is
|
||||
either switched off in the only apps that exist, or wired to nothing at all. Removing it now costs
|
||||
almost nothing; removing it after release costs a major version and other people's time.
|
||||
|
||||
## Why this is safe now
|
||||
|
||||
Two pieces of evidence, both verified rather than assumed:
|
||||
|
||||
**The only consumers already run without it**, which matters for the config files themselves even
|
||||
though the keys are inert. `D:\Company\wrnexus\apps\admin` and
|
||||
`D:\Company\wrnexus\apps\web` — both test projects, neither deployed — set every compatibility flag
|
||||
to `false` and `legacyDefaultRuntime: "current"`. They are already on the modern path; removing the
|
||||
flags means deleting the lines that say "off".
|
||||
|
||||
**None of the seven keys change any behaviour.** Verified by tracing every reference, not by
|
||||
reading the types. `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`,
|
||||
and `legacyDefaultRuntime` appear in exactly three places each: the type declaration in
|
||||
`config.ts`, the scaffolder in `create.ts`, and the insertion in `update.ts`. **No compiler,
|
||||
codegen, or runtime code reads any of them.** They are written into every generated config and then
|
||||
ignored.
|
||||
|
||||
The remaining two are the same story with more machinery. `resolveCompatibility` (`packages/styles/src/compatibility.ts`)
|
||||
produces a report — an effective date, a behaviour number, and advisory strings. Tracing every
|
||||
consumer: the `wrnexus compatibility` command prints it, and `config.ts` raises one validation error
|
||||
when the configured date is _newer_ than the CLI supports. **No compiler branch and no runtime
|
||||
behaviour reads `effectiveDate` or `effectiveBehaviour`.** The mechanism is scaffolding that was
|
||||
never connected.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- **The legacy `api` block forms.** Bare-body blocks using `with ($data)`, and `api` entries inside
|
||||
`ssr {}` / `client {}`, are _replaced_ rather than deleted — that is the next spec's job. Removing
|
||||
them here would leave a gap with no working mechanism.
|
||||
- Adding any configuration key. This spec only removes them.
|
||||
- Changing any behaviour that is currently switched on.
|
||||
|
||||
## What gets removed
|
||||
|
||||
| Item | Where | Why it goes |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
|
||||
| `compatibility: { legacyEmit, legacyEventProps, legacyComponentDiscovery, stringLayouts }` | `packages/styles/src/config.ts` (`CompatibilityConfig`) | Never read by any code |
|
||||
| `functions: { legacyDefaultRuntime }` | `packages/styles/src/config.ts` (`FunctionsConfig`) | Never read by any code |
|
||||
| `compatibilityDate` | `packages/styles/src/compatibility.ts` (`CompatibilityPolicy`) | Gates nothing |
|
||||
| `frameworkBehaviour` | same | Gates nothing |
|
||||
| `resolveCompatibility`, `CompatibilityReport`, `isCompatibilityDate`, `CURRENT_COMPATIBILITY_DATE`, `CURRENT_FRAMEWORK_BEHAVIOUR` | `packages/styles/src/compatibility.ts` | Whole module serves only the two dead keys |
|
||||
| `wrnexus compatibility <check\|explain\|upgrade>` | `packages/cli/src/compatibility-command.ts`, dispatch at `packages/cli/src/index.ts:295`, help text at `:75` | Reports on removed keys |
|
||||
| `"legacy"` variant of `FunctionRuntime` | `packages/syntax/src/v060.ts`, branches in `packages/compiler/src/client-codegen.ts` and `server-codegen.ts` | An unmarked function becomes `shared` (see below) |
|
||||
| Migrations below `0.8.0` | `packages/cli/src/update.ts` | 111 migrations reach back to `0.2.8`; no project exists below `0.8.x` |
|
||||
| Deprecated re-export shims | `packages/compiler/src/{parser,tokenizer,types}.ts` | Two lines each, re-exporting `@wrnexus/syntax` |
|
||||
| Deprecated `@wrnexus/auth` options | `engine.ts` (4 sites), `http/index.ts` (2), `plugin.ts` (2), `types.ts` (1) | See "Deprecated auth options" |
|
||||
|
||||
## The `"legacy"` function runtime
|
||||
|
||||
`FunctionRuntime` is `"legacy" | "client" | "server" | "shared"`. `"legacy"` is what an _unmarked_
|
||||
`function foo()` gets, and `legacyDefaultRuntime` decides how it behaves. Both codegens then test
|
||||
membership: `["legacy", "client", "shared"]` for the browser and `["legacy", "server", "shared"]`
|
||||
for the server — which is to say **an unmarked function is currently emitted into both bundles,
|
||||
exactly like `shared`.**
|
||||
|
||||
`legacyDefaultRuntime` looks like it should modulate this, but it is never read (above), so the
|
||||
mapping is unconditional: unmarked is always `"legacy"`, and `"legacy"` is always emitted to both
|
||||
bundles.
|
||||
|
||||
So the removal is mechanical: delete the `"legacy"` variant, and parse an unmarked function as
|
||||
`"shared"`. The emitted output for every existing unmarked function is unchanged, in every
|
||||
configuration. `FunctionRuntime`
|
||||
becomes `"client" | "server" | "shared"`, and the membership tests lose one element each.
|
||||
|
||||
This is the one item where behaviour could drift if done carelessly, so its test is explicit: an
|
||||
unmarked function must still appear in both the browser and server modules.
|
||||
|
||||
## Deprecated re-export shims
|
||||
|
||||
`packages/compiler/src/parser.ts`, `tokenizer.ts`, and `types.ts` are two-line files re-exporting
|
||||
`@wrnexus/syntax`. They are marked deprecated, but **`codegen.ts` and `native-codegen.ts` still
|
||||
import from them**, so deleting the files is not enough — those imports must be repointed at
|
||||
`@wrnexus/syntax` first. Removing the files without that step breaks the build.
|
||||
|
||||
## Deprecated auth options
|
||||
|
||||
`@wrnexus/auth` carries nine `@deprecated` markers. These are **our own superseded options**, not an
|
||||
out-of-date dependency — there is no newer version to move to, only newer options we already added.
|
||||
Removing them means deleting the old aliases and moving the few call sites that still use them.
|
||||
|
||||
| Group | Where | Replacement | Still used? |
|
||||
| --------------------------------------------------------------------- | ---------------------------- | ------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `onSignedIn` / `onSignedOut` on HTTP route options and plugin options | `http/index.ts`, `plugin.ts` | the same names on `createAuthEngine({ … })` | only `packages/auth/test/http.test.ts` and `plugin.test.ts` |
|
||||
| `onSuccessfullSignUp` (misspelled alias) | `types.ts` | `onSuccessfulSignUp` | nowhere — zero references |
|
||||
| `rpId` / `origin` on passkey verification | `engine.ts` (4 sites) | values bound to the issued challenge | `packages/auth/test/engine.test.ts` |
|
||||
|
||||
Two things this table settles:
|
||||
|
||||
- **The recommended form is already in use.** `examples/auth-showcase/app/lib/auth.ts` passes
|
||||
`onSignedIn` / `onSignedOut` to `createAuthEngine`, which is the _current_ API. The deprecated
|
||||
members are the same names on different option objects, so the example needs no change.
|
||||
- **Neither test app uses any of them.** Nothing in `D:\Company\wrnexuspps` references these
|
||||
options.
|
||||
|
||||
So the blast radius is three test files inside `packages/auth`, which are exercising the deprecated
|
||||
paths and are updated or removed alongside them. The `rpId` / `origin` options are already ignored at
|
||||
runtime — verification uses the values bound to the issued challenge — so removing them changes no
|
||||
behaviour, only the shape of the call.
|
||||
|
||||
## Migration
|
||||
|
||||
`examples/basic-app` and both test apps need one pass each:
|
||||
|
||||
1. Delete `compatibility`, `functions`, `compatibilityDate`, and `frameworkBehaviour` from
|
||||
`wrnexus.config.ts` — seven lines per app.
|
||||
2. `packages/cli/src/create.ts` stops scaffolding those keys, so new apps get a shorter config.
|
||||
|
||||
No `.wrn` source changes. Nothing in this spec alters page syntax.
|
||||
|
||||
The removed `0.2.x`–`0.7.x` migrations mean a project below `0.8.0` can no longer be upgraded by
|
||||
`wrnexus update`. No such project exists, and rescuing one would be a manual job either way.
|
||||
|
||||
## Testing
|
||||
|
||||
- **The `"legacy"` runtime removal is behaviour-preserving**: an unmarked function still appears in
|
||||
both the browser and server modules. This is the assertion most worth writing, because it is the
|
||||
only removal that could silently change output.
|
||||
- **Config rejects the removed keys** rather than ignoring them, so a stale config fails loudly with
|
||||
a message naming the key. A silently-ignored key would leave someone believing a flag still
|
||||
applies.
|
||||
- **`create.ts` scaffolds a config without them**, asserted against the generated file.
|
||||
- **`wrnexus update` still runs** with the pre-`0.8.0` migrations gone, and reports correctly for an
|
||||
app already at the current version.
|
||||
- **`examples/basic-app` builds and its suite passes** after its config is trimmed — the end-to-end
|
||||
guard that nothing depended on the removed surface.
|
||||
- **The full gate** (`bun run check:production`) passes, including the editor bundles, which embed
|
||||
the compiler and must be rebuilt after `FunctionRuntime` changes.
|
||||
|
||||
## What we give up
|
||||
|
||||
Deleting `compatibilityDate` and `frameworkBehaviour` removes the standard escape hatch for changing
|
||||
a default after going public — the mechanism that lets an existing app keep old behaviour by pinning
|
||||
a date. Today it is wired to nothing, so it protects nobody, and an unused mechanism rots rather
|
||||
than matures. If a gate is needed later it can be reintroduced deliberately, against a real
|
||||
behaviour change, instead of being carried empty. This is a considered trade rather than a free
|
||||
deletion.
|
||||
@@ -0,0 +1,276 @@
|
||||
# Typed, callable `api` blocks for `.wrn` files — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** A sectioned `api` block that declares a typed request, transforms the response, and
|
||||
handles failure — callable on demand from client code.
|
||||
|
||||
## Goal
|
||||
|
||||
Calling this application's own API routes from a `.wrn` page should be declarative and
|
||||
type-checked. Today it is neither: the `api` block takes no parameters at all, so anything
|
||||
carrying a value from the page is written as a hand-rolled `fetch` — query-string assembly,
|
||||
JSON headers, CSRF, status checks, and a `try/catch` repeated at every call site.
|
||||
|
||||
### What the current block cannot do
|
||||
|
||||
These are implementation facts, not gaps in documentation:
|
||||
|
||||
- **No query string.** `isSafeApiPath` (`packages/dev-server/src/runtime.ts`) rejects any path
|
||||
containing `?` or `#`.
|
||||
- **No interpolation.** `readPath()` reads until whitespace or `{`, so `/api/users?name={filter}`
|
||||
ends the path at the brace and the remainder is parsed as the block body.
|
||||
- **No request body.** The caller builds `new Request(apiUrl, { method, headers })` — there is no
|
||||
parameter a payload could occupy, whatever method is named.
|
||||
- **Fetch-once.** `setupCsrFetch` sets an `__wrnexusCsrFetch` flag and returns early on any later
|
||||
pass, so a binding cannot be re-run.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- External or third-party APIs. Targets are restricted to this app's `/api/*` routes, preserving
|
||||
the existing `isSafeApiPath` guarantee.
|
||||
- Replacing `server function`. That remains the way to run arbitrary server logic over RPC.
|
||||
- Author-settable headers. See "Why `headers` is excluded".
|
||||
- Parameterised server-render fetching. See "The SSR boundary".
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| Trigger | `client {}` blocks are callable on demand; `ssr {}` stays render-time |
|
||||
| Targets | This app's `/api/*` routes only |
|
||||
| Execution | Decided by the enclosing mode, not a modifier |
|
||||
| Request values | Declared fields, supplied at the call site |
|
||||
| Type source | Route contract when available, declared types otherwise (with a warning) |
|
||||
| Type enforcement | `tsc`, via assertions generated into `wrnexus.generated.api-checks.ts` |
|
||||
| Failure | `error {}` converts a failure to a value; without it, the call rejects |
|
||||
|
||||
## Syntax
|
||||
|
||||
```wrn
|
||||
client {
|
||||
api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
age?: number
|
||||
designation?: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
|
||||
error {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Called as `const users = await api.searchUsers({ name: nameFilter.trim() })`. The `api` namespace
|
||||
joins those already in client scope (`server`, `output`, `props`, `refs`), so it reads the same way
|
||||
as `server.searchUsers()`.
|
||||
|
||||
`GET` blocks declare `parameters` rather than `body`; the compiler appends them as a query string at
|
||||
call time. The path in source stays a plain literal, so `isSafeApiPath` is satisfied without
|
||||
relaxing it.
|
||||
|
||||
### Backward compatibility
|
||||
|
||||
A bare body keeps meaning "this is the response block", unchanged:
|
||||
|
||||
```wrn
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The rule is **bare body = legacy untyped block; sections = typed block.**
|
||||
|
||||
The two forms reach the payload differently, and the reason is load-bearing rather than cosmetic.
|
||||
The legacy form injects the response with `with ($data ?? {})`, which is why bare `users` resolves.
|
||||
**`with` is untypeable** — TypeScript cannot see through it — so a typed `response` block is
|
||||
impossible in that form. Sectioned blocks therefore bind the payload to `data`, specifically so
|
||||
`tsc` can check `data.users` against the route's contract.
|
||||
|
||||
### Why `headers` is excluded
|
||||
|
||||
Own-route calls are same-origin, so cookies are already attached; `content-type` and `accept` follow
|
||||
from whether the block has a body; and CSRF is attached by the runtime (below). What remains for an
|
||||
author to set is mostly credentials, which do not belong in page source. Excluded from v1 pending a
|
||||
concrete case.
|
||||
|
||||
## Type safety
|
||||
|
||||
### The constraint that shapes this
|
||||
|
||||
`examples/basic-app/tsconfig.json` uses `include: ["app"]` and excludes `.wrnexus-*`, and
|
||||
`wrnexus build` never invokes `tsc`. **Generated build artifacts are not type-checked.** Compiling
|
||||
the block into a typed client and expecting `tsc` to catch mismatches would therefore check nothing.
|
||||
|
||||
What _is_ type-checked is application source under `app/`. Enforcement goes there.
|
||||
|
||||
**Corrected 2026-08-19, during implementation.** This section originally placed the assertions in
|
||||
`app/types/wrnexus.generated.d.ts`. That is inert: the root `tsconfig.json` sets
|
||||
`skipLibCheck: true`, which exempts the _contents_ of every `.d.ts`, so an assertion written there
|
||||
can never raise a `tsc` error. Proven by forcing `skipLibCheck: false`, under which the same
|
||||
assertion fires as `TS2344`. The reasoning was right and the file was wrong. Per-block assertions
|
||||
are emitted into a real `.ts` file instead — `app/types/wrnexus.generated.api-checks.ts` — which
|
||||
`skipLibCheck` does not exempt and which `include: ["app"]` compiles. The helper types stay in the
|
||||
`.d.ts`, where being declarations is correct.
|
||||
|
||||
### Three pieces
|
||||
|
||||
**1. Helper types**, extending what the generator already emits (`ApiRoute`, `ApiContracts`,
|
||||
`ApiContract`):
|
||||
|
||||
```ts
|
||||
type AssertAssignable<Actual, Expected> = Actual extends Expected ? true : never;
|
||||
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||
```
|
||||
|
||||
**2. Per-block assertions**, generated into `app/types/wrnexus.generated.api-checks.ts`. `wrnexus generate types` already parses
|
||||
`.wrn` sources to build the route list, so it can read each block's declared fields and emit:
|
||||
|
||||
```ts
|
||||
type __wrn_check_searchUsers = AssertAssignable<
|
||||
{ name?: string; age?: number },
|
||||
ApiInput<"/api/users", "POST">
|
||||
>;
|
||||
```
|
||||
|
||||
This is what makes the safety real. It sits in a file the project's own `tsc` already compiles, so
|
||||
`bun run typecheck` fails when a block sends a field the endpoint rejects. No bespoke type
|
||||
comparison inside the WRNexus compiler, and no need to type-check build output. The language server
|
||||
already runs TypeScript diagnostics on `.wrn` documents, so the same error appears inline.
|
||||
|
||||
**3. A generic runtime**, `callApi(path, method, input)`, typed by those contracts, so the fetch,
|
||||
JSON handling, and failure branch live in one tested place instead of being re-emitted per block.
|
||||
|
||||
### Routes without a contract
|
||||
|
||||
A plain handler returning `Response.json` has no `defineEndpoint` contract, so `ApiInput` resolves
|
||||
to `unknown`. The block's declared types are used directly and the generator emits a warning naming
|
||||
the route. Untyped endpoints stay visible rather than silently passing.
|
||||
|
||||
### GET parameters travel as strings
|
||||
|
||||
A `GET` block's `parameters` become a query string (see Request assembly below), and every
|
||||
`URLSearchParams` value is text on the wire regardless of the declared field type — a block
|
||||
declaring `age?: number` still sends and receives `"30"`, not `30`. The declared type is honest
|
||||
only because the endpoint's own schema coerces it back: `checkField` in
|
||||
`packages/validation/src/index.ts` calls `Number(pre)` for every `v.number()` field — optional or
|
||||
required — before the handler ever sees it, so `defineEndpoint({ input: v.object({ age:
|
||||
v.number() }) })` invoked as `?age=30` hands the handler an actual `number` (verified end to end;
|
||||
regression-tested in `packages/core/test/endpoint-schema.test.ts`, "a GET request coerces a
|
||||
v.number() query param to an actual number"). This is a property of the endpoint's schema, not of
|
||||
the `api` block or the generated contract types — a route that reads `ctx.url.searchParams`
|
||||
directly, with no `defineEndpoint` schema, receives raw strings and gets no coercion, but that
|
||||
route also has no contract for the generator to check against, so it already falls under "Routes
|
||||
without a contract" above and is flagged there.
|
||||
|
||||
### Staleness
|
||||
|
||||
Checking is only as current as the generated file, so this stays wired into the existing
|
||||
`check:generated-types` gate, which already verifies those artifacts match their sources.
|
||||
|
||||
## Compilation and runtime
|
||||
|
||||
### Client mode
|
||||
|
||||
Each `client { api name ... }` becomes an entry on an `api` namespace in the generated browser
|
||||
module, beside the existing `__wrnexusClientFunctions`, with `const api = context.api` added to
|
||||
client scope exactly as `server` is today.
|
||||
|
||||
Declared field types are **type-only**. The generator uses them for the `.d.ts` assertions and
|
||||
codegen drops them before emit. A client function body that carried TypeScript into a `.mjs`
|
||||
artifact is a bug this repository has already shipped once (fixed 2026-08-19, `55fed217`); the same
|
||||
discipline applies here.
|
||||
|
||||
`setupCsrFetch` is untouched. Callable blocks are a separate mechanism, so the existing render-time
|
||||
binding needs no rework.
|
||||
|
||||
### Request assembly
|
||||
|
||||
`callApi` builds the request:
|
||||
|
||||
- **GET** — declared `parameters` become a query string; `undefined` fields are omitted, which
|
||||
removes the `if (filter.trim())` ladder authors write by hand.
|
||||
- **Everything else** — a JSON body with `content-type: application/json`.
|
||||
- Always `credentials: "same-origin"` and `accept: application/json`.
|
||||
- **Non-GET requests attach `x-csrf-token`**, read from the `wrn-csrf` cookie or the
|
||||
`wrnexus-csrf` meta tag, reusing the logic already at `packages/csr/src/reactive-runtime.ts:4221`
|
||||
for RPC. Hand-written `fetch` calls in application code generally omit this, so it is a
|
||||
correctness gain rather than only less typing.
|
||||
|
||||
### Failure
|
||||
|
||||
**`error {}` converts a failure into a value; without it, the call rejects.**
|
||||
|
||||
- 2xx — the JSON is parsed and bound as `data`, `response {}` runs, and its return value is the
|
||||
call's result. With no `response` block, `data` is returned unchanged.
|
||||
- Non-2xx, network failure, or an unparseable body — `error {}` runs with `status`, `message`, and
|
||||
`data` in scope. `return []` yields an empty list and no exception.
|
||||
- No `error {}` block — the promise rejects, so `try/catch` at the call site keeps working.
|
||||
|
||||
A block must never quietly return `undefined` on failure. Success-shaped failure is the defect class
|
||||
this design is most concerned with, so the absence of an `error` block means throw, never swallow.
|
||||
|
||||
### The SSR boundary
|
||||
|
||||
`ssr {}` blocks accept `response {}` and `error {}`, but **not** `request {}`. There is no caller at
|
||||
render time to supply arguments, and inferring an implicit source — page state, query parameters —
|
||||
would be a guess. Parameterised requests are a client-mode feature; parameterised server-side
|
||||
fetching stays with `server function`.
|
||||
|
||||
## Testing
|
||||
|
||||
### Parser (`packages/syntax/test/`)
|
||||
|
||||
- A sectioned block parses into `request`/`response`/`error` parts.
|
||||
- A bare body still parses as the response block (the backward-compatibility guarantee).
|
||||
- `request` inside an `ssr {}` block is a parse error naming the restriction.
|
||||
- A malformed section reports the offending offset rather than failing later in codegen.
|
||||
|
||||
### Type generation (`packages/cli/test/` or the types generator's suite)
|
||||
|
||||
- A block targeting a `defineEndpoint` route emits an assertion referencing that contract.
|
||||
- A field the endpoint does not accept makes `bun run typecheck` fail — asserted by running `tsc`
|
||||
over a fixture, not by string-matching the generated file.
|
||||
- A block targeting a contract-less route emits the warning and falls back to declared types.
|
||||
- `check:generated-types` still passes with blocks present.
|
||||
|
||||
### Codegen (`packages/compiler/test/`)
|
||||
|
||||
- A client-mode block emits an `api` namespace entry and valid JavaScript — no TypeScript survives
|
||||
into the browser module (the guard for the `55fed217` defect class).
|
||||
- Declared types do not appear in the emitted module.
|
||||
- An `ssr` block's output is unchanged from today for a bare body.
|
||||
|
||||
### Runtime (`packages/csr/test/`)
|
||||
|
||||
- GET omits `undefined` parameters and includes the rest.
|
||||
- Non-GET attaches `x-csrf-token` from cookie and from meta.
|
||||
- 2xx runs `response`; its return value is the result.
|
||||
- Non-2xx runs `error`; its return value is the result.
|
||||
- With no `error` block, a non-2xx rejects rather than resolving to `undefined`.
|
||||
|
||||
### End to end (`examples/basic-app`)
|
||||
|
||||
A page calling a typed block against a real route, driven in a browser: the request carries the
|
||||
declared fields, the response block's value reaches page state, and a deliberately failing call
|
||||
takes the `error` path. Tests that pass while the feature does not work have been a recurring
|
||||
failure in this repository, so browser verification is part of the definition of done.
|
||||
|
||||
## Deferred
|
||||
|
||||
- External and third-party API targets, with the allowlist and credential handling they require.
|
||||
- Author-settable request headers.
|
||||
- Re-runnable `ssr` bindings — `setupCsrFetch`'s fetch-once guard stays as it is.
|
||||
- Parameterised server-render fetching.
|
||||
- Response caching and request de-duplication.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Carrying projects to the new syntax with `wrnexus update` — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** Migrations that take an existing project from today's syntax to the syntax left by the
|
||||
cleanup and `apis { }` specs.
|
||||
|
||||
**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md` and
|
||||
`2026-08-19-apis-block-design.md`. Both define the target this migrates to, so both must land first.
|
||||
|
||||
## Goal
|
||||
|
||||
After the two preceding specs, every existing project is written in a syntax the framework no longer
|
||||
accepts. One `wrnexus update` should carry a project across — config keys removed, `api` blocks
|
||||
moved into `apis { }`, mode-scoped helpers relocated — and, where it cannot do that safely, say so
|
||||
precisely instead of guessing.
|
||||
|
||||
## What already exists
|
||||
|
||||
This is an extension of working machinery, not a new subsystem:
|
||||
|
||||
- `Migration { version, id, description, apply(ctx) }`, run when `from < version <= to`.
|
||||
- `MigrationCtx` carries `appRoot`, `from`, `to`, **`dryRun`**, a report, and a logger.
|
||||
- `MigrationReport` already separates `changedAutomatically`, `needsReview`, `parseFailures`,
|
||||
`ambiguousFunctions`, and `unresolvedImports`.
|
||||
- `update.ts` already imports `parse` and `formatWrn` from `@wrnexus/syntax`, so parsing a `.wrn`
|
||||
file, transforming it, and re-emitting formatted source is an established pattern here.
|
||||
|
||||
The report's shape matters: it was built around the idea that some changes are safe to make and
|
||||
others must be handed back to a human. That distinction is the backbone of this spec.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Migrating projects below `0.8.0`. The cleanup spec removes those migrations; no such project
|
||||
exists.
|
||||
- Rewriting application logic. Only the constructs these specs changed.
|
||||
|
||||
## The safety contract
|
||||
|
||||
**A file is transformed correctly, or it is left untouched and reported.** There is no third
|
||||
outcome. Concretely:
|
||||
|
||||
1. Parse the file. A parse failure records the path in `parseFailures` and moves on — the file is
|
||||
never partially rewritten.
|
||||
2. Transform, then re-emit through `formatWrn`.
|
||||
3. If any part of a file's transform cannot be completed, **the whole file is skipped** and recorded
|
||||
in `needsReview` with the reason and the construct involved.
|
||||
|
||||
Running the migration twice must be a no-op: every transform detects already-migrated input and
|
||||
does nothing. Dry-run must report exactly what a real run would change.
|
||||
|
||||
## The migrations
|
||||
|
||||
### 1. Remove the dead config keys
|
||||
|
||||
Delete `compatibility`, `functions`, `compatibilityDate`, and `frameworkBehaviour` from
|
||||
`wrnexus.config.ts`. Mechanical, fully automatic, and safe because none of them was ever read.
|
||||
|
||||
### 2. `ssr { api … }` / `client { api … }` → `apis { … }`
|
||||
|
||||
Move each `api` entry into a page-level `apis { }` block, dropping the mode. Sectioned bodies —
|
||||
those already using `request` / `response` / `error` — carry across unchanged, because the payload
|
||||
is already bound to `data`.
|
||||
|
||||
Fully automatic. If a page has entries in both an `ssr` and a `client` block sharing a name, that is
|
||||
a duplicate under the new rules and the **file is skipped and reported**, since choosing which one
|
||||
survives is a decision about intent.
|
||||
|
||||
### 3. `ssr { functions { … } }` → `functions { shared function … }`
|
||||
|
||||
Relocate mode-scoped helpers to the page-level `functions { }` block with the `shared` modifier.
|
||||
Automatic. If the page already has a function of the same name, the file is skipped and reported.
|
||||
|
||||
### 4. Legacy bare-body `api` blocks — **needs review, not automatic**
|
||||
|
||||
This is the one transform that cannot be done safely, and the spec is explicit about it rather than
|
||||
attempting a best effort.
|
||||
|
||||
A legacy bare body is evaluated inside `with ($data ?? {})`, so it references payload fields as bare
|
||||
identifiers:
|
||||
|
||||
```wrn
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return userNames(users)
|
||||
}
|
||||
```
|
||||
|
||||
The sectioned form binds the payload to `data`, so this must become `data.users`. But **which free
|
||||
identifiers are payload fields is not knowable from the source.** In the example, `users` comes from
|
||||
the response and `userNames` is a page helper — and nothing in the file distinguishes them. The
|
||||
response shape belongs to the route, and the route may not even be typed.
|
||||
|
||||
A migration that guessed would produce code that compiles and is wrong: `data.userNames(...)` or an
|
||||
untouched `users` that silently resolves to `undefined`. That is precisely the silent-wrong-answer
|
||||
failure this project keeps paying for.
|
||||
|
||||
So: legacy bare-body blocks are **detected, reported in `needsReview` with the file, the block name,
|
||||
and the free identifiers found**, and left untouched. The report tells the author exactly what to
|
||||
decide. `wrnexus update` prints a short explanation of why this one is manual.
|
||||
|
||||
### 5. Deprecated `@wrnexus/auth` options
|
||||
|
||||
Only if the cleanup spec's optional auth section is included. Rename call sites of the superseded
|
||||
options. Automatic where the rename is unambiguous; reported otherwise.
|
||||
|
||||
## Version
|
||||
|
||||
All of these attach to the release that ships the breaking change. After the cleanup spec the
|
||||
migration floor is `0.8.0`, so the list is short and every entry is reachable.
|
||||
|
||||
## Output
|
||||
|
||||
At the end of a run the command prints, in this order: what it changed, what needs review and why,
|
||||
and what failed to parse. A run with anything in `needsReview` or `parseFailures` exits non-zero, so
|
||||
a scripted upgrade cannot appear to succeed while leaving a project half-migrated.
|
||||
|
||||
## Testing
|
||||
|
||||
Each migration gets a fixture project and three assertions: the transform produces the expected
|
||||
source, running it a second time changes nothing, and a dry run reports the same set without writing.
|
||||
|
||||
- **Config removal** — keys gone, rest of the config untouched.
|
||||
- **`api` relocation** — a page with both `ssr` and `client` api blocks lands in one `apis { }`;
|
||||
entries keep their names, methods, paths, and sections.
|
||||
- **Name collision across modes** — the file is skipped and reported, not silently merged.
|
||||
- **Mode functions** — relocated with the `shared` modifier; a name collision skips and reports.
|
||||
- **Legacy bare body** — reported in `needsReview` with the block name and free identifiers, and the
|
||||
file is byte-identical afterwards. This is the most important test in the spec: it pins that the
|
||||
migration does _not_ attempt the rewrite.
|
||||
- **Parse failure** — a malformed `.wrn` is recorded in `parseFailures` and left untouched.
|
||||
- **Exit code** — non-zero when anything needs review.
|
||||
- **End to end** — `examples/basic-app` migrated by the command alone, then built and tested. If the
|
||||
framework's own example cannot be migrated by the tool, the tool is not finished.
|
||||
|
||||
## What this does not promise
|
||||
|
||||
Automated source rewriting cannot be promised as "perfect". What is promised is bounded: every file
|
||||
is either correctly transformed or untouched and named in the report, with the reason. Nothing is
|
||||
half-rewritten, and nothing is guessed. The legacy bare-body case is deliberately manual because a
|
||||
correct automatic answer does not exist.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Component showcase audit
|
||||
|
||||
Reviewed all 102 component routes for loading, empty examples, and desktop page overflow. This is a route and rendering audit, not exhaustive testing of every prop combination.
|
||||
|
||||
Corrections include:
|
||||
|
||||
- Corrected the showcase's Tailwind source paths so package-owned icons are generated.
|
||||
- Reduced advanced select and PIN input sizing and corrected search icon padding.
|
||||
- Restored grouped combobox options and verified selection.
|
||||
- Defaulted ToggleCount to its first valid choice and supplied meaningful monthly/yearly examples.
|
||||
- Made the password leading icon optional without reserving empty space.
|
||||
- Added populated accordion examples with plain, bordered, and separated layouts, plus start/end indicators.
|
||||
- Corrected badge examples and theme-aware soft/outline colors.
|
||||
- Normalized structured playground defaults, including tables and time pickers.
|
||||
- Preserved server-rendered composite previews during hydration and subsequent playground updates.
|
||||
- Supplied splitter pane content and made output observation compatible with both runtime registry forms.
|
||||
|
||||
Validation includes component/showcase tests, compiler and accessibility checks for 201 example pages, production showcase build, browser interaction checks, and responsive checks. Visual-contract checks compare source hashes, not screenshots.
|
||||
|
||||
## Follow-up review: component surfaces and playground
|
||||
|
||||
Reviewed all 102 sidebar routes in order, inspecting the rendered examples for missing icon masks, empty content, and horizontal overflow. This is a structural pass, not exhaustive testing of every prop combination. Manually checked the reported accordion, alert, avatar group, button group, card, and carousel surfaces, plus numeric playground interaction and prop updates.
|
||||
|
||||
- Playground input/change refreshes are restricted to the settings form. Interacting with the component no longer replaces its state. A regression test checks both paths.
|
||||
- Sidebar active state refreshes on client navigation and scrolls the active entry into view without scrolling the page.
|
||||
- Accordion answers align with trailing indicators, and separate panels no longer get a second outer border.
|
||||
- Alerts use concise examples with zero or one action and theme-aware text colors.
|
||||
- Avatar groups have named members and role details for hover/focus; overflow counts stay on one line.
|
||||
- Button groups include their own button foundation and correctly update selected state.
|
||||
- Cards no longer receive generic group arrays by default; group content has padding and responsive columns.
|
||||
- Carousel supports `framed=false` by default and `framed=true` explicitly. Unframed removes the outer surface as well as the viewport border. Image captions have a contrast overlay.
|
||||
- Preview grid children can shrink within their panels; password popovers and breadcrumbs respect their container width.
|
||||
|
||||
Validation: 243 UI/showcase tests passed; 201 example pages passed compiler, accessibility, and formatter checks. Browser interaction checks cover numeric value persistence, settings regeneration, accordion expansion, button selection, carousel next-slide, and avatar focus details. Integrations that need external providers and every optional prop permutation are not covered by these checks.
|
||||
|
||||
Phone-width review: all 102 pages fit the viewport at 390px. Fixed inner overflow in password requirements and pagination. Verified light/dark stylesheet switching and saved appearance preferences; added a regression for switching before the theme runtime loads.
|
||||
|
||||
|
||||
## Catalog and interaction update
|
||||
|
||||
The catalog now separates 84 primitive components, 14 blocks, and six standalone examples. `/preview` and `/templates` link to working pages with copyable source; the legacy design-system routes and four requested shell components are removed from the showcase. Package exports remain compatible.
|
||||
|
||||
Rebuilt Rating, StyledIcon, ListGroup, DragAndDrop, DataMap and the basic rich-text editor. Map demonstrates an image-backed office plan with markers and zoom; it is not a geographic tile service. DataMap is an activity heatmap. These examples require no third-party provider.
|
||||
|
||||
Fixed anchored calendar/time overlays, datetime selection, controlled modal rendering, table page-size selection, optional clipboard labels, input icon placement, slider alignment and stateful stepper navigation. Existing keyed rows stay attached when notifications are appended, avoiding animation restarts. Added image and headerless card examples and meaningful chat, tree, chart and upload data.
|
||||
|
||||
Validation: 380 UI, showcase and CSR tests pass; 211 example pages pass compiler/accessibility/formatter checks; the showcase production build succeeds. Browser checks confirm external modal open/close, anchored calendar, rating selection, list reordering/editor input, right-click menu, table size 25 with matching selected item, gallery routes and sidebar active state. These checks do not cover every optional configuration or external provider integration.
|
||||
|
||||
|
||||
## Follow-up: controls and gallery separation
|
||||
|
||||
Added fractional Rating and RangeSlider examples, named Card header/footer slots with visibility controls, custom TimePicker within DatePicker, calendar completion/outside dismissal, implicit switch label association, native search-clear suppression, multi-file names, interactive LegendIndicator, bar/line Chart data props and expanded rich-text formatting. Centered modals use data-modal-close for custom dismissal buttons. Confetti now uses a canvas particle burst with reduced-motion handling.
|
||||
|
||||
Map defaults to an OpenStreetMap geographic embed; Google Maps supports an Embed API key, query, coordinates, zoom and map type. The showcase allows only the two provider iframe origins. Google rendering still requires a user-supplied provider key.
|
||||
|
||||
Examples and Blocks now use a gallery layout without a documentation sidebar. Templates provide four distinct product starting pages: dashboard, commerce, marketing and productivity. Removed the legacy v0.6 sidebar group, placed Advanced Forms after Tables, and added practical code to installation/customization/accessibility guides. Playground code initializes from the actual controls and array props use state bindings.
|
||||
|
||||
Browser checks include calendar Done, nested custom time updates, switch label toggling, centered modal dismissal, loaded geographic map tiles, and visible canvas confetti. Production preview avoids development refresh resets during review.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Compact application design
|
||||
|
||||
The UI package now uses a quiet neutral canvas, compact application headings,
|
||||
consistent controls, and layered charcoal surfaces in dark mode. Marketing
|
||||
display typography remains available through Hero and MarketingSectionHeader.
|
||||
|
||||
## Shared defaults
|
||||
|
||||
- Control heights: 32px small, 40px default, 44px large; larger targets on coarse pointers.
|
||||
- Corners: 6px controls, 10px panels, 12px dialogs.
|
||||
- Flat cards and metric panels, with elevation reserved for overlays and explicit variants.
|
||||
- No global entry animation or automatic hover lift on every button and linked article.
|
||||
- PageHeader and SectionHeader use application-scale headings.
|
||||
- DatePicker aligns days to weekdays, respects month length and date bounds, and supports arrow keys and Escape.
|
||||
|
||||
Configure `radius-sm`, `radius-md`, `radius-lg`, `control-height-sm`,
|
||||
`control-height`, `control-height-lg`, `space-panel`, and the existing semantic
|
||||
color/shadow tokens through `theme.themes`. Geometry and elevation are no longer
|
||||
overwritten by a second global token declaration in the generated stylesheet.
|
||||
Existing names, slots, and events remain available. MetricCard's default hover
|
||||
is now `none`; use `hover="lift"` to retain the explicit elevated treatment.
|
||||
|
||||
## Review pages
|
||||
|
||||
Run the component showcase and open:
|
||||
|
||||
- `/design-system`: composed workspace overview and invitation dialog.
|
||||
- `/design-system/profile`: profile form and preferences.
|
||||
- `/design-system/login`: quiet split sign-in layout.
|
||||
- `/components/date-picker`: calendar variants and controls.
|
||||
|
||||
The preview uses sample data and never sends invitations or sign-in requests.
|
||||
Light and dark buttons use the native theme stylesheet switcher. The showcase
|
||||
generator preserves the navigation link; these three authored pages are not
|
||||
generated or overwritten by it.
|
||||
|
||||
## Adopting the refresh
|
||||
|
||||
Consumers receive the shared component styles and theme defaults when they adopt
|
||||
the updated packages. Review application overrides for large headings, nested
|
||||
cards, generous gaps, and hard-coded colors. The example showcase, CRM, and basic
|
||||
application styles have been adjusted here. The deployed Account application's
|
||||
page source is outside this repository and still needs its own layout migration.
|
||||
|
||||
Validate light/dark, narrow viewports, keyboard interaction, long text, and custom
|
||||
theme overrides before refreshing the visual-contract baseline. That contract
|
||||
is a file-hash check, not screenshot comparison.
|
||||
@@ -2,109 +2,109 @@
|
||||
"schemaVersion": 1,
|
||||
"releaseLine": "0.8",
|
||||
"artifacts": {
|
||||
"packages/ui/components/Accordion.wrn": "09251f76a2f9385e97eb67e06c0cf868e1771ac5f420647221bec5160287034b",
|
||||
"packages/ui/components/AdvancedSelect.wrn": "b952b894a5b35050fc2f6e43c4182101eeee05c75662e2aca4abfe61512b305b",
|
||||
"packages/ui/components/AnnouncementBar.wrn": "cb66b8d0619ee798a115b43f0ad93fda93dfb46571589e7a9f0a8d65ec3c84c9",
|
||||
"packages/ui/components/AuthForm.wrn": "46a3e1db1c9e69efc52473cbf2fababdcbfe7144e7571469638d2c3addd4d7f0",
|
||||
"packages/ui/components/AuthSplitLayout.wrn": "724938921bc38cc5a0422de69c237b630f099f2df94c66977362b048e4a420a5",
|
||||
"packages/ui/components/AvatarGroup.wrn": "30b702e06a1adff8b9bae32820206020e086a69560cbdcbadb19f04f25951f97",
|
||||
"packages/ui/components/BackToTop.wrn": "5b760ec7941ffd8e58c5dedae1b322ee46e072e927533d7ec077b511a816e436",
|
||||
"packages/ui/components/Blockquote.wrn": "3761bf13babb6eb07ce7392258ee439ef5478064cf589eff63f945b950e00d2e",
|
||||
"packages/ui/components/Breadcrumb.wrn": "9f6c23550ebfc635c36ca8660953775170b4a471c0cd0206b9a7ba760994980a",
|
||||
"packages/ui/components/ButtonGroup.wrn": "fc23565ad56bd8483fcb777d9e02ccba2f9ea476c085754d42503bb83b718f72",
|
||||
"packages/ui/components/CTASection.wrn": "d0665f2aa33eea95e6f84120f739b3dd9c8723c882138bcb1a7f80dcd7909904",
|
||||
"packages/ui/components/Card.wrn": "3cc2ff238bda279c169026a462236144ad11ec0046deef00ab00e001a07e8a4c",
|
||||
"packages/ui/components/Carousel.wrn": "e7d921f802aa19210f3f415b59b70750a2c8d78e30684ad334809741c70a6bf9",
|
||||
"packages/ui/components/Chart.wrn": "07bc01247b5a1c2b82c2ba8386b7fcdbf480efead75d52b7da04d471072c7ee4",
|
||||
"packages/ui/components/Accordion.wrn": "f3480c567498f9a26a3441f4e96f47034b80422062fe8484b9957286f4a2fec1",
|
||||
"packages/ui/components/AdvancedSelect.wrn": "ab75393f1f21654da43004267cd039e51a92aac0dd8bdf7a970f8b7830159ce9",
|
||||
"packages/ui/components/AnnouncementBar.wrn": "ecf85f5a050ef1afa193d85aa6acdb31638e48191594ddcc4f51cad4a77935dd",
|
||||
"packages/ui/components/AuthForm.wrn": "9e65faa7780d75abae2f1fe4a2333f4134840bca6c66b7411979d770f9050bc2",
|
||||
"packages/ui/components/AuthSplitLayout.wrn": "f29a8f374007b7342d6946d7c57e631abb9730f4d952942606b9b1cf67612c38",
|
||||
"packages/ui/components/AvatarGroup.wrn": "96a3ac42b72854449a89fcb87e62c616f78315d630ed9fa5da1e61db4c2a13ce",
|
||||
"packages/ui/components/BackToTop.wrn": "d4b8d1fd37ff26c89b4276ce54ce6ac9581dc2ff8bbcbe160e072778bba76eab",
|
||||
"packages/ui/components/Blockquote.wrn": "bc1ce41a5986a563072639e7f641d55b47816a8ec615169fe329c7a3ee24a16e",
|
||||
"packages/ui/components/Breadcrumb.wrn": "2f1a48a0b3fe1d5e8c36ee5929a034cb6ed4c4f6fb7031085829f20c4aac008e",
|
||||
"packages/ui/components/ButtonGroup.wrn": "b841e02296f90e86ffea6573a6f3824d04b51cc8e5e822c7a29fcc0661e6fca2",
|
||||
"packages/ui/components/CTASection.wrn": "f6872b980432d5ee9d88388250be9838ccce51bd706349b283ef472ee692b237",
|
||||
"packages/ui/components/Card.wrn": "08849755d68b067e8b14275d97a65cd93a74932ca1c8f0daa4e042cdf049e471",
|
||||
"packages/ui/components/Carousel.wrn": "f3f2d70709fb25185ac6546ea5b6d394362bd2e0623e1462c439fba9a25e5f37",
|
||||
"packages/ui/components/Chart.wrn": "80ef5b8e0e79ad494b3d21aedf36e01bffe9a50e4f43c0bd5af083efc11a5cab",
|
||||
"packages/ui/components/ChatBubble.wrn": "d71f7d6567d76ac0eb3ecc8e2e567fec12bb3236ea05f4d0a72286abc0b7cbb1",
|
||||
"packages/ui/components/Clipboard.wrn": "5a93f2bae337d7ce364229b796bac673f60dde6dc135f0969f4e106e04854d30",
|
||||
"packages/ui/components/Clipboard.wrn": "3de4c3d6e94472651fe241ae20f0e37f2cd6d82b3bbae86ba155d8a3ba1b955e",
|
||||
"packages/ui/components/Collapse.wrn": "26df087a61c82f1aa1749785f4c9be654c4f934859c1a47acfcb8cc67eecc75d",
|
||||
"packages/ui/components/ColorPicker.wrn": "557e36adbdee66d8ba9dcdc2446c13f3385a243f2766777feef8504908006c99",
|
||||
"packages/ui/components/ColorPicker.wrn": "bc55991a3d98d2018a0c9c125f399d519a7240d7a3c769460500b70ca7f6bf3a",
|
||||
"packages/ui/components/Columns.wrn": "8ccd9e53f6799b09f8b996740d12e4e73ee782f6fd3e57595c6c16f788b9b812",
|
||||
"packages/ui/components/Combobox.wrn": "58488dbb60e41dacbf223afdb4a05abdbec53b7f54cd79a0db659add7057b1c1",
|
||||
"packages/ui/components/Confetti.wrn": "0e81426a3ee8aff672385a5466cf96085c25ba17677a2fef432bcddf13f4d58e",
|
||||
"packages/ui/components/Combobox.wrn": "e6f2ecf84213e3c4343154a3d1b7a9bc46ec5121855cb9fe65b105edd73c261d",
|
||||
"packages/ui/components/Confetti.wrn": "0d3c36e77ac49f23bc02bfc8f338a034348c174e25bbc6a34f70310ccabee7b9",
|
||||
"packages/ui/components/Container.wrn": "61c895a9cc0e02d314392e94226bddfae2b8fad074e30a68ba935e6ec9549ea6",
|
||||
"packages/ui/components/ContextMenu.wrn": "1b078b9ac20ce2561167b0e14cec1fc26704c251d7a25021b45e51e1b980946e",
|
||||
"packages/ui/components/ContextMenu.wrn": "0c505787136a608097ba996b0c5e1be45b20be136a00611ec720a5d2c728c91c",
|
||||
"packages/ui/components/CustomScrollbar.wrn": "2a3a2719580c21823d079c92ebb000c7a5a7d9d16e3fa9275b8fb7d4449cb437",
|
||||
"packages/ui/components/DataMap.wrn": "338f0d0a3307289dd0387b7fe7ba631fa0b65b6be8c7b9ee3feb2203306f76a3",
|
||||
"packages/ui/components/DataTable.wrn": "e929fdb4e59613878a03fb6a11e09c42f773c06ca7b20e43ae16a266835f2af4",
|
||||
"packages/ui/components/DatePicker.wrn": "91bcfd3832d3bccc4de5402c0c06209947bde593bad4f869b4c29ee14a5c0bef",
|
||||
"packages/ui/components/DataMap.wrn": "cb6da3ab064ea62ef14a30af42344ca1bb1dd816fec7aea520cbe47133f539ff",
|
||||
"packages/ui/components/DataTable.wrn": "eceac58d7f007066108016cbc196faf381d5a84e4f5f23d2feda217182996248",
|
||||
"packages/ui/components/DatePicker.wrn": "07b2685e311eb8211f81a99a28553783d19b1cd99e4cec591fe4f4953ac3fe6b",
|
||||
"packages/ui/components/DeviceFrame.wrn": "c38d25419ee44b05d7c443cc4e854aeeb7a53a9289e374d71834de2dd2e4d979",
|
||||
"packages/ui/components/Divider.wrn": "bfa36cf16ccd48c996bb0b08a438f1b281ea1811049ed69fc7680925b1eba019",
|
||||
"packages/ui/components/DragAndDrop.wrn": "84305f07d43cdb9eff1f63c19458d88b0aec013424d05a49a2cafbecedc60513",
|
||||
"packages/ui/components/Drawer.wrn": "5692db9a627587951898c2392789ef9dcae1662f19663607171a17d8f4ddb023",
|
||||
"packages/ui/components/Dropdown.wrn": "978367a9dde22448cc664d686e4dd9c579fe8ba0b2cf02d610d95a3501f8dbb0",
|
||||
"packages/ui/components/FeatureCard.wrn": "1bc548c115f924a0c7ade4c557852757083755c60ca0ee747b150a7cca3ebced",
|
||||
"packages/ui/components/FeatureGrid.wrn": "15b5d6f9ddb7e3697682e7247fdf3c2dd080b770335f84ba9fab2692166fe897",
|
||||
"packages/ui/components/FeatureIconCard.wrn": "3b4f3fa62c6729e686886a5b848e26c255766684829cec2b715967b4e73d6f07",
|
||||
"packages/ui/components/FileInput.wrn": "866a292a3280527bf429893469e7c50f7cf38965d8adb37b45a35ede1606d5b8",
|
||||
"packages/ui/components/FileUploadProgress.wrn": "e5da29c562a521cdd6bb50b8f4d217aa1ab981d7b2a2432af47391472f0a8034",
|
||||
"packages/ui/components/Footer.wrn": "f7c5a77064a09e244f689d42475c4c20143f4c8859cac451c10a50c14652db33",
|
||||
"packages/ui/components/Divider.wrn": "f806d899bfd2d0b6e40aa226919101002704ddbddf0ee0b5b6a56c2452693660",
|
||||
"packages/ui/components/DragAndDrop.wrn": "86382288ca2bccd8aa853490186fcf3b07e7850587fd0fe5829fd40590b82cc3",
|
||||
"packages/ui/components/Drawer.wrn": "d11c11631b5a6779df1db987dd2da9125202237e5f378d6645dd260809f80cb6",
|
||||
"packages/ui/components/Dropdown.wrn": "4649fa306fe4cd6ced3a8a3b7d46e2d835d7cae0335ee8fce0632ff87fc548c9",
|
||||
"packages/ui/components/FeatureCard.wrn": "39d89caab070b2b9ecb554ec6d83aad90aeb5b0daa8ab7c8b2f3c7f9e3350077",
|
||||
"packages/ui/components/FeatureGrid.wrn": "af2ff8f8c806091cb5cff3d93c7b121b73a6580ac5c9be3ab357355a09084096",
|
||||
"packages/ui/components/FeatureIconCard.wrn": "5f65d64235babc3e653a5695204412202015e6f3ffadca566fc18c7f9ce70428",
|
||||
"packages/ui/components/FileInput.wrn": "3071a9231ce3c42555a30ad246841bc5d3a00b2a5475b403ecd5b33130ebadf5",
|
||||
"packages/ui/components/FileUploadProgress.wrn": "645b0c4ce031fdbd70e8534d95d1b8d46fff9f67f5d90d2a7c125a6c656b3584",
|
||||
"packages/ui/components/Footer.wrn": "4df610f3f57950b43738c305e8100464326e094140013673d723f3454297f1da",
|
||||
"packages/ui/components/Grid.wrn": "83d4f5f2656d539538723f5791ba3c238901432e9d8c55c74f068d3eb5a74517",
|
||||
"packages/ui/components/Hero.wrn": "345478b212701817ff57060f87982a987baf01a7179c979768e1cd4218b50900",
|
||||
"packages/ui/components/Hero.wrn": "d0311f566a1ffdfb97a8b05c58bce738e3147fe5a3208cb2ef6968e6e178e8d1",
|
||||
"packages/ui/components/HeroActions.wrn": "67cd31400ccb6abdbbf16219267dee946b44b064b79f25276d20ceb7d6e8a790",
|
||||
"packages/ui/components/Image.wrn": "361179c478d7674c3b998891a366d73de9514fc6cc8786440c6c50b9ef065357",
|
||||
"packages/ui/components/InputGroup.wrn": "30e9f2e4364542179180b6d6e796002d0ffe1e30305146749874697cde6629ea",
|
||||
"packages/ui/components/InputGroup.wrn": "a0f570a350b32ffbe9bbeb81a32afab2966ccc5232b3affed4cba2f2a1e7b05e",
|
||||
"packages/ui/components/InputNumber.wrn": "069195cc483f3f7d6f305d03fbe5a821906a030376fafb6f0d969e4c95b68fd0",
|
||||
"packages/ui/components/Kbd.wrn": "6b402cb93f0fa76bb978d1ba483215f70ec7214b1c5e84b568fa3e26e87ed9d9",
|
||||
"packages/ui/components/LayoutSplitter.wrn": "41d070e801a271552023e4392e4c0f67327c61e2e8eaee5f8b87af254b1d79c1",
|
||||
"packages/ui/components/LegendIndicator.wrn": "b2b75a2a5edc20a326c2047d4c81b9cd3f319292a1eec5f4abd5cbafc0677d71",
|
||||
"packages/ui/components/LegendIndicator.wrn": "b01886495cd54be2c3e8ffbe99cfd5d12b4e0e35f5e4134041d45f4ed6057ff8",
|
||||
"packages/ui/components/Link.wrn": "13f9d6b403440f4b3c3a9ce4dbefa0d5570eb30ff93f498e49f18baab3e08c1d",
|
||||
"packages/ui/components/List.wrn": "5bd7d0b12b2f7dbf730db4030c6e20c6617b0933c1341428bbd6bcdab9ab6073",
|
||||
"packages/ui/components/ListGroup.wrn": "eb7eca762101ea873fd6fa2f12f427580130fba3b21c0448a551b0452997dc92",
|
||||
"packages/ui/components/Map.wrn": "3e6d5de5a21dd050c5c65950d6fb2f89af4024e1856a48f055b65b88fcd7b890",
|
||||
"packages/ui/components/ListGroup.wrn": "dd52c1f564b8ef3293269b3aab15b72ca737c8ce9866d0070574ca5469aae82d",
|
||||
"packages/ui/components/Map.wrn": "f09651baad892640a8d822c7f13541311b3de7f6259252bfacf937580fab3a44",
|
||||
"packages/ui/components/MarketingSectionHeader.wrn": "39b79499b77dc1c7d34e4e5ecb45a9ddc6182f78c2a2586671c4fa07f44679ad",
|
||||
"packages/ui/components/Marquee.wrn": "81cd80732be34c8d7e4bf28b00347e1a9273166afdf66589d9f5d5774b69403b",
|
||||
"packages/ui/components/MegaMenu.wrn": "6a6db166259f8f2bd7ac8fbb21dba6d915c4b936d87b19805caa78189f54730a",
|
||||
"packages/ui/components/MetricCard.wrn": "513a6e99be3d9db46d96af9942b51331a0a048b184603e77e814506f0a52bdc5",
|
||||
"packages/ui/components/MetricGrid.wrn": "c71e53249835908833055b553e64a8ee55fdf11ac4605436a9581ebb87a0608b",
|
||||
"packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2",
|
||||
"packages/ui/components/MegaMenu.wrn": "3f37ee6b7c0d14bf550e8c828f1902be86969969354307efe5b61c1d727ce942",
|
||||
"packages/ui/components/MetricCard.wrn": "f195a9413591000f0ce9265f58cb45ae7a67ac08134213cfae7ffe75d53bab6a",
|
||||
"packages/ui/components/MetricGrid.wrn": "1fcebf51dcbe8520fe16bda904c1ef206c25c3900438d53877492c64ea21a1a5",
|
||||
"packages/ui/components/Modal.wrn": "ddd560010581fb63f6dd8f51ddde9e8e7a5adb781b743c06eae28ac557f662d3",
|
||||
"packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a",
|
||||
"packages/ui/components/Navbar.wrn": "01903230210cd2e5e6d9325a0708f85af9623d4118e3a53f1adb770fd545425d",
|
||||
"packages/ui/components/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54",
|
||||
"packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39",
|
||||
"packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602",
|
||||
"packages/ui/components/Popover.wrn": "1246b99b4236bc7109a5d1427d013bb07bbea7a2da4f821517d2545ee0b5c019",
|
||||
"packages/ui/components/PortalDashboard.wrn": "f04171eb1f8cabef06de3c20e4694b766def3407e1e20d2c9aaebc9bb7da03ee",
|
||||
"packages/ui/components/PreferenceSwitcher.wrn": "77229e15449182563ebf49159507139928eb74e799e4438e00e9aa81fd60f982",
|
||||
"packages/ui/components/Navbar.wrn": "c9c1290954b1953fd7812d86bc261f4244f1e88eff5d2ebdf0b4a6fcd55df6d9",
|
||||
"packages/ui/components/PageHeader.wrn": "a3e2c59041b9fdbf67188e2f3ea602aec487f22c14c7ca96c3d2b00d17a0725e",
|
||||
"packages/ui/components/Pagination.wrn": "0049cc61f65f0db934cfc2d255656294b6188f29133f8ca0b716332a92595a5d",
|
||||
"packages/ui/components/PinInput.wrn": "9b5112e6dbe1e16448b2a4f87d9f088519779779c9d0ec2bdd34e1e56be7faba",
|
||||
"packages/ui/components/Popover.wrn": "322b6056633fb4f2211d2a67896cb12ae30b0b9f1ff582f6e520fa7ab6fabc86",
|
||||
"packages/ui/components/PortalDashboard.wrn": "dea35ce15889fb673adbd965f11838e83dc937b13e57b00bc0af0c3a5a8ff5c7",
|
||||
"packages/ui/components/PreferenceSwitcher.wrn": "6869b8b2efdbf42d9ec7bc79bd7cdce5997e5623c9aeddb01221f7fe1d2d1c98",
|
||||
"packages/ui/components/PublicPageShell.wrn": "3d3f25c03d48f3d3d7e3c567e79cb137c7fce1867e054fd6d440fa1d2e961077",
|
||||
"packages/ui/components/RangeSlider.wrn": "bbf646c5d5a89a62986290db811d77b8f635ebcaea9ca2d334b569aa5edc785b",
|
||||
"packages/ui/components/Rating.wrn": "902b55c64e3798f3097f09b1558d1a2372f66d4f760662de085783108ee04587",
|
||||
"packages/ui/components/Scrollspy.wrn": "b9c6400ee92a7c228362610a2051f37bff1d3baba1dbacd66d58397dfda5d856",
|
||||
"packages/ui/components/SearchBox.wrn": "3f88340e0b69c0cd552023c72bd22dc511bc00422d46060097bcf0777e683675",
|
||||
"packages/ui/components/Section.wrn": "45b7a1977c25a6020d5cc8bab2ce9c80730e7ec2d51ad53e8c9f64cd9b5b64cd",
|
||||
"packages/ui/components/SectionHeader.wrn": "aca2df8eb35806096f5b9af466b018e5c22a19422e8665f488380da577a0ceb5",
|
||||
"packages/ui/components/Sidebar.wrn": "e0250a973aea589a4d7dcc8b30dd01f22592bf22ba012e54a672b2a2288d8af6",
|
||||
"packages/ui/components/SplitHero.wrn": "843387dc7b920b3cf6a1b2b785b8653c281982e524479edc2638a89381ff3577",
|
||||
"packages/ui/components/StatsBar.wrn": "c273bd6ca417a0a20e5e6f1214414f678741b73bedd885fea1037575fa8c5b95",
|
||||
"packages/ui/components/Stepper.wrn": "7ec0902acd7cb74b44d490215b6421dae9a41731735ad1e9aec17e8bfc3fa142",
|
||||
"packages/ui/components/StrongPassword.wrn": "d5ea906fafe268b070a26ab551a252c0575d899c9d2b03b337bd047688b85dc5",
|
||||
"packages/ui/components/StyledIcon.wrn": "0874f9a234b053a7c8b9c6983be5d664453a64c4576dff1a049101e249a2866d",
|
||||
"packages/ui/components/Tabs.wrn": "f85214960e62da2fac41228deba511a50c57fd94f2830a8233b75048f6a542e9",
|
||||
"packages/ui/components/RangeSlider.wrn": "37868f30ef15401d4ba6cca3f54d54ebac71c7bd20728d4bd95f80e844ee607c",
|
||||
"packages/ui/components/Rating.wrn": "fbcc5b9c67ded0e3dc70cb0ab645046b02e27f17f006ded09558e28af6f24c81",
|
||||
"packages/ui/components/Scrollspy.wrn": "4b452fcbd524feda9c9f2c81894ee01747a51888e3b86fafb37c5d4d192a3a51",
|
||||
"packages/ui/components/SearchBox.wrn": "90f58b7375bae2cf14e31d2c48a8c451f891a823f616c1f01075aca0762543ee",
|
||||
"packages/ui/components/Section.wrn": "78da13eb8bc1acafe8e6151cbaeed4baaac2ab860e3209f1d13c467f279d2e68",
|
||||
"packages/ui/components/SectionHeader.wrn": "428c0c313c562987a4022671246b942d722dd2520b6cadd6f1a7644c7b56d4ee",
|
||||
"packages/ui/components/Sidebar.wrn": "ee817a9a46b4d6ab5fea4ebef6d4b28b3ea4599eb7f5b0cc81aa4be1769af792",
|
||||
"packages/ui/components/SplitHero.wrn": "7127cb869d1228522d066e028818dd9e512de8b6dd8f99e3dec500e6da5a3cb0",
|
||||
"packages/ui/components/StatsBar.wrn": "d4922c94962a957f428fa0e61743a76706ce38698fbf638d065c7fadde0eabe7",
|
||||
"packages/ui/components/Stepper.wrn": "07d9a90681b9415696603e06e8cf2aac2692f42fd5744d5655482a6092a5536b",
|
||||
"packages/ui/components/StrongPassword.wrn": "9a2d59169ceed5af09e48412f4b50cf36e66a889317b8e15f01744cc47a8b69f",
|
||||
"packages/ui/components/StyledIcon.wrn": "ca620463eecbf61d5465ca43513b29d7e175bdc784a3debc2f5b1a8d076fd2fa",
|
||||
"packages/ui/components/Tabs.wrn": "2ca14d0a87cff3066334f1295fd066a787b84b5667962c1d22fb879b4a68cea6",
|
||||
"packages/ui/components/TextLink.wrn": "9d561631c5f65a2bcdc59f5c0b166a5dd794a396f42093f2cd26388386f6f954",
|
||||
"packages/ui/components/TimePicker.wrn": "ec0860a4a82a042a65acf9aa81519a5c815b61e0b8080cf6f1c986d26ce7f0e8",
|
||||
"packages/ui/components/Timeline.wrn": "fb553f69763840532eef1c0aafa79013f57411dc5335e7cb7e7c20c5a3753008",
|
||||
"packages/ui/components/Toaster.wrn": "5e4c9b2c1bfd5675a81a32e57684b99393ac7fe45d8420c7409a4e95d47d080c",
|
||||
"packages/ui/components/ToggleCount.wrn": "e577195ee05ddb43cceec47145f6b90c0149f2f17055733d58911fcd02de2c7f",
|
||||
"packages/ui/components/TogglePassword.wrn": "b6176453cb2c586fe88c3db6bdd734ae2b4c95db46d57e0de298dfba3d0dd0c2",
|
||||
"packages/ui/components/TimePicker.wrn": "4b7985fa5cec727a02012763fc7ab8ffee1dee5e54aa512da5bfccde398811c9",
|
||||
"packages/ui/components/Timeline.wrn": "7a58a77b1addf2ebbb0eb5dad008bcb49696c4265456d388d1cea71492c76ca6",
|
||||
"packages/ui/components/Toaster.wrn": "de36dad25b30944800ab42d52492f18cbe5eca90ba17e8f70eb25690942d2c60",
|
||||
"packages/ui/components/ToggleCount.wrn": "8370443edb8e15734448e4fd92c0ec3a573980de0402a6adcd24d62567112c4a",
|
||||
"packages/ui/components/TogglePassword.wrn": "3f75a93f607472e73b357fa41b63cd2240b6fd561d03cfd31f92c69944431546",
|
||||
"packages/ui/components/TreeView.wrn": "f54d8495730203b923629bcfb167d5ca41236af0101ea2723d33354413555b4b",
|
||||
"packages/ui/components/Typography.wrn": "9676af57f2a937b21f45512e0a290b5bee4a16267fbf404850297d29645d88b5",
|
||||
"packages/ui/components/WysiwygEditor.wrn": "3f9d1765184581e3c1f335769a9f19969917450258bc95ba076980d99f5610b5",
|
||||
"packages/ui/components/alert.wrn": "d42287e41b918b1e19962e9462ef203002eb5480acac984f115b711e9dbe663d",
|
||||
"packages/ui/components/WysiwygEditor.wrn": "6ae997ff6f3835b2ff3e4e3f050bcaa040efddba08fca9dc6d6d2c8d3cf4737b",
|
||||
"packages/ui/components/alert.wrn": "e14e5ca056e27d36816d6949c4c56fbdbe9a881adb15036d6c8749c40dc451dd",
|
||||
"packages/ui/components/avatar.wrn": "8bc705e459b13c7f063c57e1506ce540e50fa8ef9020ae85b6e8b6a3579fc5dc",
|
||||
"packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1",
|
||||
"packages/ui/components/button.wrn": "cba4ccbbd23bb75b3836ec7a53673e40f1e043d18bfcee3d613db96c318f4ba8",
|
||||
"packages/ui/components/checkbox.wrn": "18046396b75d0b9c6bb2bdb09dbff1846c84fb07490f390a25ca7b5367fd2ef4",
|
||||
"packages/ui/components/input.wrn": "e4c14527f009b610b4aa96d8d7b7d5ac3ca1ffd34237bab348ee9dc4ddc55847",
|
||||
"packages/ui/components/badge.wrn": "f29605b692601d12fb0a9df3ee62d16c35fb03cf6448e3fef4402a56766d6842",
|
||||
"packages/ui/components/button.wrn": "70681183850d5ddc1c0c23c52382ee2d40544c7bd8f0afe9850323f4805bfa31",
|
||||
"packages/ui/components/checkbox.wrn": "67d148bf0482a3161b0393fd7a1aa3a7789de1173272abcdbb650e3a4a1377c7",
|
||||
"packages/ui/components/input.wrn": "a074338ed781e34e020ca5585e73ab6dd6f9cf3ad94bf2911088b38d2ba1dffb",
|
||||
"packages/ui/components/progress.wrn": "6307a90585197d7aab19a8710b2430f5d4ed27ce77e9b90b1414ea0eed876492",
|
||||
"packages/ui/components/radio.wrn": "09425a78358de5bbd2f47482f313e80065135335b969ce5ccdd5c7cc3ea5232c",
|
||||
"packages/ui/components/select.wrn": "1d8d47a74d5ab9ed58d0ba11f46910897233b96652d17f5962f5dda60bd4cf8a",
|
||||
"packages/ui/components/radio.wrn": "deb3412256fdd66128943773bbd7b826c760968cdc51131417038699c35526f8",
|
||||
"packages/ui/components/select.wrn": "499113d8ac4b6d090adc515cac27285ba9cdd8af7ea54f18f61b0086da76e8c0",
|
||||
"packages/ui/components/skeleton.wrn": "4fc5e0846eeefd7830c038e1789be995c4f9d833aa913ff079eb4863baa65648",
|
||||
"packages/ui/components/spinner.wrn": "2322645da7ef53f7c06035ff071d9a2f6ffe2901ff9338daed84037369367105",
|
||||
"packages/ui/components/switch.wrn": "874504e4828e9db6a570d78984c4d3a76d0ceb39d70baa877649cb3798c82c85",
|
||||
"packages/ui/components/textarea.wrn": "9870d37664471a103d44435a3977bf40b087a743acd76ee4d77df9b56cccbb2b",
|
||||
"packages/ui/components/switch.wrn": "471e1bfd99e535818787bb19e35db800d3e24db051417cea89c72b96694ac073",
|
||||
"packages/ui/components/textarea.wrn": "5b2614cf2cb869897d29c359812af3e2b0d6fe278ee2bbf6d99a92521eeaddf2",
|
||||
"packages/ui/components/tooltip.wrn": "f3dfdfa5cd9661fe5e95ef3580eef4c7437c069726421370c2d7328fbe7d840d",
|
||||
"packages/ui/styles/SelectStyles.wrn": "074fe0d67de4ef5e9f9cfe879beb72fd5c83352888687d3a1a4b7722c87af3ca",
|
||||
"packages/ui/ui.css": "9ea591404e9cf675bbf1003e15e9fad00327094ff217dc20733f0fc87c0f4a62"
|
||||
"packages/ui/styles/SelectStyles.wrn": "0d000c18419187de9ab5c8bf1b86db1912cd5a4d90591f0da0da3533e10b8baa",
|
||||
"packages/ui/ui.css": "27c2709b6b3a07b4c9a8bef8c2464159a54798a4c76384abd0707135b79ef437"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.12
|
||||
|
||||
- Fixed false TypeScript diagnostics for `{t:key}` translations in component props and native HTML attributes. Mixed translated/dynamic attributes still validate ordinary expressions.
|
||||
- Fixed inline event handlers containing multiple statements, and infer `event.currentTarget` from the bound HTML/SVG element.
|
||||
- Rebuilt the bundled language server from the shared framework typechecker; real unknown-name, invalid-property and nullable DOM diagnostics remain enabled.
|
||||
|
||||
## 0.8.9
|
||||
|
||||
- Added highlighting for the page-level `apis { }` block and its `<name> <METHOD> <path>` entries,
|
||||
along with container and entry completion snippets.
|
||||
- Added `api.` call completion listing every declared block with its method and path, and hover
|
||||
reporting a block's method, path, and request fields.
|
||||
- Taught the language server the `api="…"` binding attribute: it is no longer treated as unknown,
|
||||
and completion inside its quotes offers the page's block names.
|
||||
- Diagnostics for the removed `ssr { … }` / `client { … }` data blocks now name the `apis { }`
|
||||
replacement and land on the offending keyword instead of the start of the file.
|
||||
- Fixed `api.<name>()` calls reporting a spurious "Cannot find name 'api'" error, and the implicit
|
||||
`any` that followed from it.
|
||||
- Restricted API hover to real `api.<name>` references, so a local variable that merely shares a
|
||||
block's name keeps its own hover information.
|
||||
- `client state { }`, `runtime = "client"`, and the `client function` modifier are unaffected —
|
||||
only the `ssr { }` / `client { }` data-block form was removed.
|
||||
|
||||
## 0.8.8
|
||||
|
||||
- Added HTML tag and attribute completions inside WRN `view` blocks, while preserving WRNexus
|
||||
component completion priority and suppressing HTML suggestions outside markup regions.
|
||||
- Added HTML hover documentation, folding ranges, linked tag editing, and automatic closing tags.
|
||||
- Added Emmet expansion support for WRN documents and kept void elements from receiving closing tags.
|
||||
- Hardened completion and auto-close handling against quoted attribute values, replaced selections,
|
||||
stale asynchronous edits, and duplicate client-side suggestions.
|
||||
|
||||
## 0.8.3
|
||||
|
||||
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
||||
@@ -16,8 +48,8 @@
|
||||
- Kept component prop/event intelligence active while the shared language server is enabled.
|
||||
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
||||
diagnostics in packaged extension environments.
|
||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers such as
|
||||
`output[type](payload)` by preserving JavaScript semantics for omitted parameter types.
|
||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers that
|
||||
dispatch a payload by event type, preserving JavaScript semantics for omitted parameter types.
|
||||
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
||||
consistently in the repository and extension development environment.
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "wrnexus",
|
||||
"dependencies": {
|
||||
"vscode-html-languageservice": "^5.6.2",
|
||||
"vscode-languageclient": "^10.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vscode/vsce": "^3.9.2",
|
||||
},
|
||||
@@ -88,6 +92,8 @@
|
||||
|
||||
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.6", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og=="],
|
||||
|
||||
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||
|
||||
"@vscode/vsce": ["@vscode/vsce@3.9.2", "", { "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", "@secretlint/secretlint-formatter-sarif": "^10.1.2", "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", "cheerio": "^1.0.0-rc.9", "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", "glob": "^13.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", "minimatch": "^10.2.2", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "optionalDependencies": { "keytar": "^7.7.0" }, "bin": { "vsce": "vsce" } }, "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA=="],
|
||||
|
||||
"@vscode/vsce-sign": ["@vscode/vsce-sign@2.0.9", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g=="],
|
||||
@@ -542,6 +548,20 @@
|
||||
|
||||
"version-range": ["version-range@4.15.0", "", {}, "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg=="],
|
||||
|
||||
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="],
|
||||
|
||||
"vscode-jsonrpc": ["vscode-jsonrpc@9.0.1", "", {}, "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw=="],
|
||||
|
||||
"vscode-languageclient": ["vscode-languageclient@10.1.0", "", { "dependencies": { "minimatch": "^10.2.5", "semver": "^7.8.1", "vscode-languageserver-protocol": "3.18.2", "vscode-languageserver-textdocument": "1.0.13" } }, "sha512-XXRx6lqVitQy/oOLr9MfNYRG+MbQkhXkDaxbQMiKxEm8zZNfheRFUKNb8UYNh2stn9btl2wQM5wZFJjJvoc+jA=="],
|
||||
|
||||
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.2", "", { "dependencies": { "vscode-jsonrpc": "9.0.1", "vscode-languageserver-types": "3.18.0" } }, "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg=="],
|
||||
|
||||
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
||||
|
||||
"vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
@@ -582,6 +602,8 @@
|
||||
|
||||
"table/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"vscode-languageclient/vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.13", "", {}, "sha512-nx0ZHwMGIsVkzFG3/VLeJYBLTaFBRuNdGDvevvjuoayU5EOS2fEYazOhtCM3PI9ClMMg5igc0uwXtAq4tJj+Dw=="],
|
||||
|
||||
"@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "wrnexus",
|
||||
"displayName": "WRNexus Language Support",
|
||||
"description": "Complete WRNexus v0.8.3 language support for typed imports, props, state, outputs, runtime functions, stores, diagnostics, formatting, navigation, and migration assistance.",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.12",
|
||||
"publisher": "wrnexus",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -134,6 +134,11 @@
|
||||
"maximum": 240,
|
||||
"scope": "resource",
|
||||
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
||||
},
|
||||
"wrnexus.html.autoClosingTags": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically close HTML tags inside .wrn view blocks."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -141,6 +146,9 @@
|
||||
"files.associations": {
|
||||
"*.wrn": "wrn"
|
||||
},
|
||||
"emmet.includeLanguages": {
|
||||
"wrn": "html"
|
||||
},
|
||||
"[wrn]": {
|
||||
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||
"editor.formatOnSave": false,
|
||||
@@ -276,13 +284,14 @@
|
||||
"check": "bun run build && bun run test && bun run validate",
|
||||
"vscode:prepublish": "bun run check",
|
||||
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
||||
"publish": "vsce publish --no-dependencies --no-rewrite-relative-links",
|
||||
"publish:azure": "vsce publish --no-dependencies --no-rewrite-relative-links --azure-credential"
|
||||
"publish": "vsce publish --no-dependencies",
|
||||
"publish:azure": "vsce publish --no-dependencies --azure-credential"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vscode/vsce": "^3.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"vscode-languageclient": "^10.1.0"
|
||||
"vscode-languageclient": "^10.1.0",
|
||||
"vscode-html-languageservice": "^5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use strict";
|
||||
|
||||
const vscode = require("vscode");
|
||||
|
||||
/**
|
||||
* Auto-close tags as they are typed.
|
||||
*
|
||||
* LSP has no request for this, so the client watches document changes and asks
|
||||
* the server whether the tag should close. The server owns the decision because
|
||||
* void elements and already-closed tags must not be closed.
|
||||
*/
|
||||
function registerAutoCloseTags(context, client) {
|
||||
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||
if (event.document.languageId !== "wrn") return;
|
||||
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
|
||||
|
||||
const changes = event.contentChanges;
|
||||
if (!changes.length) return;
|
||||
|
||||
const typed = changes[0].text;
|
||||
if (typed !== ">" && typed !== "/") return;
|
||||
// Every cursor must have typed the same trigger. A replaced selection
|
||||
// (overtype, or select-and-type) is declined rather than guessed at.
|
||||
if (!changes.every((change) => change.text === typed && change.rangeLength === 0)) return;
|
||||
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document !== event.document) return;
|
||||
|
||||
/*
|
||||
* Positions come from the editor's selections, not from the changes.
|
||||
*
|
||||
* A change's `range` is in coordinates from before the whole event, so with
|
||||
* several cursors on one line every range after the first is short by the
|
||||
* insertions preceding it. The selections have already been adjusted for
|
||||
* the edit, so they are where the carets actually are.
|
||||
*/
|
||||
const positions = editor.selections.map((selection) => selection.active);
|
||||
if (positions.length !== changes.length) return;
|
||||
if (!editor.selections.every((selection) => selection.isEmpty)) return;
|
||||
|
||||
const documentVersion = event.document.version;
|
||||
const snippets = await Promise.all(
|
||||
positions.map((position) =>
|
||||
client.sendRequest("wrn/tagComplete", {
|
||||
textDocument: { uri: event.document.uri.toString() },
|
||||
position: { line: position.line, character: position.character },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (!snippets.every((snippet) => typeof snippet === "string" && snippet)) return;
|
||||
/*
|
||||
* One insertSnippet call carries one snippet, and it is the only form that
|
||||
* keeps every caret: inserting sequentially would collapse the selection to
|
||||
* the first snippet and invalidate the remaining positions. Cursors that
|
||||
* want different closing tags are therefore declined rather than
|
||||
* half-applied -- multi-cursor editing of matching lines, which is what
|
||||
* this is for, produces one snippet for all of them.
|
||||
*/
|
||||
if (!snippets.every((snippet) => snippet === snippets[0])) return;
|
||||
|
||||
// The user may have kept typing during the round-trip; re-validate everything the
|
||||
// insertion depends on before touching the document, since a stale offset would
|
||||
// silently corrupt it.
|
||||
if (vscode.window.activeTextEditor !== editor) return;
|
||||
if (editor.document !== event.document) return;
|
||||
if (editor.document.version !== documentVersion) return;
|
||||
if (editor.selections.length !== positions.length) return;
|
||||
if (
|
||||
!editor.selections.every(
|
||||
(selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||
});
|
||||
|
||||
context.subscriptions.push(listener);
|
||||
}
|
||||
|
||||
module.exports = { registerAutoCloseTags };
|
||||
+1117
-282
File diff suppressed because it is too large.
Load diff
@@ -306,6 +306,54 @@ const BLOCK_COMPLETIONS = [
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "apis",
|
||||
detail: "Page-level API call declarations block",
|
||||
documentation:
|
||||
"Declare the API calls available to this page as named entries, each callable via api.<name>() from functions and the `api=` view binding.",
|
||||
snippet: [
|
||||
"apis {",
|
||||
" ${1:searchUsers} ${2|GET,POST,PUT,PATCH,DELETE|} ${3:/api/users} {",
|
||||
" request {",
|
||||
" body {",
|
||||
" ${4:name}?: ${5:string}",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" response {",
|
||||
" return ${6:data}",
|
||||
" }",
|
||||
"",
|
||||
" error {",
|
||||
" return ${7:null}",
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "apis entry",
|
||||
detail: "Single API call declaration",
|
||||
documentation:
|
||||
"Declare a single named API call inside an apis { } block: <name> <METHOD> <path> { request/response/error }.",
|
||||
snippet: [
|
||||
"${1:searchUsers} ${2|GET,POST,PUT,PATCH,DELETE|} ${3:/api/users} {",
|
||||
" request {",
|
||||
" body {",
|
||||
" ${4:name}?: ${5:string}",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" response {",
|
||||
" return ${6:data}",
|
||||
" }",
|
||||
"",
|
||||
" error {",
|
||||
" return ${7:null}",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "persist",
|
||||
detail: "Include-only store persistence",
|
||||
@@ -570,6 +618,41 @@ function isInsideWatch(document, position) {
|
||||
return depth > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an offset sits inside a `view { }` block.
|
||||
*
|
||||
* The language server owns completion there and returns a merged list, so this
|
||||
* provider stands down to avoid VS Code concatenating two independent lists.
|
||||
* Quotes are only tracked inside a tag: `<p>it's</p>` would otherwise open a
|
||||
* string that never closes.
|
||||
*/
|
||||
function isInsideViewBlock(text, offset) {
|
||||
const pattern = /\bview\s*\{/g;
|
||||
let match;
|
||||
while ((match = pattern.exec(text))) {
|
||||
const start = match.index + match[0].length;
|
||||
let depth = 1;
|
||||
let inTag = false;
|
||||
let quote = null;
|
||||
let index = start;
|
||||
for (; index < text.length && depth > 0; index += 1) {
|
||||
const char = text[index];
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (inTag && (char === '"' || char === "'")) quote = char;
|
||||
else if (char === "<") inTag = true;
|
||||
else if (char === ">") inTag = false;
|
||||
else if (char === "{") depth += 1;
|
||||
else if (char === "}") depth -= 1;
|
||||
}
|
||||
if (offset >= start && offset <= index) return true;
|
||||
pattern.lastIndex = index;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAfterWatchKeyword(document, position) {
|
||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||
|
||||
@@ -634,6 +717,8 @@ function addFunctionCompletions(items, document) {
|
||||
}
|
||||
|
||||
function provideCompletionItems(document, position) {
|
||||
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
|
||||
|
||||
const items = [];
|
||||
|
||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||
@@ -707,6 +792,7 @@ module.exports = {
|
||||
extractProps,
|
||||
extractRouteParams,
|
||||
extractStates,
|
||||
isInsideViewBlock,
|
||||
provideCompletionItems,
|
||||
registerCompletionProvider,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@
|
||||
const path = require("node:path");
|
||||
const vscode = require("vscode");
|
||||
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
||||
const { registerAutoCloseTags } = require("./auto-close-tags.js");
|
||||
|
||||
const WRN_LANGUAGE_ID = "wrn";
|
||||
/** @type {LanguageClient | undefined} */
|
||||
@@ -43,6 +44,7 @@ async function activate(context) {
|
||||
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
||||
);
|
||||
await client.start();
|
||||
registerAutoCloseTags(context, client);
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
@@ -51,4 +53,4 @@ async function deactivate() {
|
||||
if (running) await running.stop();
|
||||
}
|
||||
|
||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
||||
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||
+23174
-170
File diff suppressed because it is too large.
Load diff
@@ -113,10 +113,10 @@
|
||||
"include": "#action-block"
|
||||
},
|
||||
{
|
||||
"include": "#mode-block"
|
||||
"include": "#api-block"
|
||||
},
|
||||
{
|
||||
"include": "#api-block"
|
||||
"include": "#apis-block"
|
||||
},
|
||||
{
|
||||
"include": "#functions-block"
|
||||
@@ -513,8 +513,8 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"mode-block": {
|
||||
"begin": "\\b(ssr|client)\\b\\s*(\\{)",
|
||||
"apis-block": {
|
||||
"begin": "\\b(apis)\\b\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.wrn"
|
||||
@@ -534,21 +534,18 @@
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"begin": "\\b(api)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
|
||||
"begin": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.wrn"
|
||||
},
|
||||
"2": {
|
||||
"name": "entity.name.function.wrn"
|
||||
},
|
||||
"3": {
|
||||
"2": {
|
||||
"name": "constant.language.http-method.wrn"
|
||||
},
|
||||
"4": {
|
||||
"3": {
|
||||
"name": "string.unquoted.route.wrn"
|
||||
},
|
||||
"5": {
|
||||
"4": {
|
||||
"name": "punctuation.definition.block.begin.wrn"
|
||||
}
|
||||
},
|
||||
@@ -558,8 +555,36 @@
|
||||
"name": "punctuation.definition.block.end.wrn"
|
||||
}
|
||||
},
|
||||
"contentName": "meta.embedded.block.ts",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"begin": "\\b(request|response|error)\\b\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.wrn"
|
||||
},
|
||||
"2": {
|
||||
"name": "punctuation.definition.block.begin.wrn"
|
||||
}
|
||||
},
|
||||
"end": "\\}",
|
||||
"endCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.block.end.wrn"
|
||||
}
|
||||
},
|
||||
"contentName": "meta.embedded.block.ts",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#ts-braces"
|
||||
},
|
||||
{
|
||||
"include": "source.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"include": "#ts-braces"
|
||||
},
|
||||
@@ -567,9 +592,6 @@
|
||||
"include": "source.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"include": "#functions-block"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const { installVsCodeHost } = require("./vscode-host.js");
|
||||
|
||||
class Position {
|
||||
constructor(line, character) {
|
||||
this.line = line;
|
||||
this.character = character;
|
||||
}
|
||||
translate(lineDelta, characterDelta) {
|
||||
return new Position(this.line + lineDelta, this.character + characterDelta);
|
||||
}
|
||||
isEqual(other) {
|
||||
return this.line === other.line && this.character === other.character;
|
||||
}
|
||||
}
|
||||
|
||||
class Selection {
|
||||
constructor(active) {
|
||||
this.active = active;
|
||||
this.anchor = active;
|
||||
this.isEmpty = true;
|
||||
}
|
||||
}
|
||||
|
||||
class SnippetString {
|
||||
constructor(value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
let changeListener = null;
|
||||
const host = {
|
||||
Position,
|
||||
Selection,
|
||||
SnippetString,
|
||||
workspace: {
|
||||
onDidChangeTextDocument(listener) {
|
||||
changeListener = listener;
|
||||
return { dispose() {} };
|
||||
},
|
||||
getConfiguration() {
|
||||
return { get: (_key, fallback) => fallback };
|
||||
},
|
||||
},
|
||||
window: { activeTextEditor: null },
|
||||
};
|
||||
|
||||
const restoreHost = installVsCodeHost(host);
|
||||
const { registerAutoCloseTags } = require("../src/auto-close-tags.js");
|
||||
restoreHost();
|
||||
|
||||
/**
|
||||
* Drive the handler the way VS Code does: the document has already been
|
||||
* updated and the carets moved by the time the change event fires.
|
||||
*/
|
||||
function scenario({ carets, snippetFor, typed = ">" }) {
|
||||
const inserted = [];
|
||||
const asked = [];
|
||||
const document = { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } };
|
||||
const editor = {
|
||||
document,
|
||||
selections: carets.map((caret) => new Selection(caret)),
|
||||
insertSnippet(snippet, positions) {
|
||||
inserted.push({ value: snippet.value, positions });
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
};
|
||||
editor.selection = editor.selections[0];
|
||||
host.window.activeTextEditor = editor;
|
||||
|
||||
const client = {
|
||||
sendRequest(_method, params) {
|
||||
asked.push(params.position);
|
||||
return Promise.resolve(snippetFor(params.position));
|
||||
},
|
||||
};
|
||||
|
||||
registerAutoCloseTags({ subscriptions: [] }, client);
|
||||
|
||||
return {
|
||||
inserted,
|
||||
asked,
|
||||
fire: () =>
|
||||
changeListener({
|
||||
document,
|
||||
// Pre-edit coordinates, deliberately not usable as caret positions.
|
||||
contentChanges: carets.map(() => ({
|
||||
text: typed,
|
||||
rangeLength: 0,
|
||||
range: { start: new Position(0, 0) },
|
||||
})),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("closes the tag at a single caret", async () => {
|
||||
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||
await run.fire();
|
||||
|
||||
assert.equal(run.inserted.length, 1);
|
||||
assert.equal(run.inserted[0].value, "$0</div>");
|
||||
assert.deepEqual(
|
||||
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||
[[1, 8]],
|
||||
);
|
||||
});
|
||||
|
||||
test("closes the tag at every caret in one insertion", async () => {
|
||||
// One insertSnippet call is what keeps all the carets alive: inserting
|
||||
// sequentially would collapse the selection to the first snippet.
|
||||
const run = scenario({
|
||||
carets: [new Position(1, 8), new Position(2, 8), new Position(3, 8)],
|
||||
snippetFor: () => "$0</div>",
|
||||
});
|
||||
await run.fire();
|
||||
|
||||
assert.equal(run.asked.length, 3);
|
||||
assert.equal(run.inserted.length, 1);
|
||||
assert.deepEqual(
|
||||
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||
[
|
||||
[1, 8],
|
||||
[2, 8],
|
||||
[3, 8],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("asks about each caret's own position rather than the change ranges", async () => {
|
||||
// Every contentChange above reports (0, 0). Using those would query and
|
||||
// insert at the wrong offsets once more than one caret is on a line.
|
||||
const run = scenario({
|
||||
carets: [new Position(4, 12), new Position(9, 3)],
|
||||
snippetFor: () => "$0</p>",
|
||||
});
|
||||
await run.fire();
|
||||
|
||||
assert.deepEqual(
|
||||
run.asked.map((p) => [p.line, p.character]),
|
||||
[
|
||||
[4, 12],
|
||||
[9, 3],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("declines when the carets want different closing tags", async () => {
|
||||
const run = scenario({
|
||||
carets: [new Position(1, 8), new Position(2, 8)],
|
||||
snippetFor: (position) => (position.line === 1 ? "$0</div>" : "$0</span>"),
|
||||
});
|
||||
await run.fire();
|
||||
|
||||
assert.equal(run.inserted.length, 0);
|
||||
});
|
||||
|
||||
test("declines when any caret has no tag to close", async () => {
|
||||
const run = scenario({
|
||||
carets: [new Position(1, 8), new Position(2, 8)],
|
||||
snippetFor: (position) => (position.line === 1 ? "$0</br>" : null),
|
||||
});
|
||||
await run.fire();
|
||||
|
||||
assert.equal(run.inserted.length, 0);
|
||||
});
|
||||
|
||||
test("declines a replaced selection", async () => {
|
||||
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||
await changeListener({
|
||||
document: { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } },
|
||||
contentChanges: [{ text: ">", rangeLength: 3, range: { start: new Position(1, 5) } }],
|
||||
});
|
||||
|
||||
assert.equal(run.inserted.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { installVsCodeHost } = require("./vscode-host.js");
|
||||
|
||||
const restoreHost = installVsCodeHost({
|
||||
Position: class Position {
|
||||
constructor(line, character) {
|
||||
this.line = line;
|
||||
this.character = character;
|
||||
}
|
||||
},
|
||||
Range: class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
},
|
||||
CompletionItem: class CompletionItem {
|
||||
constructor(label, kind) {
|
||||
this.label = label;
|
||||
this.kind = kind;
|
||||
}
|
||||
},
|
||||
CompletionItemKind: {
|
||||
Event: 23,
|
||||
Property: 10,
|
||||
Function: 12,
|
||||
Keyword: 14,
|
||||
Variable: 13,
|
||||
},
|
||||
SnippetString: class SnippetString {
|
||||
constructor(text) {
|
||||
this.value = text;
|
||||
}
|
||||
},
|
||||
MarkdownString: class MarkdownString {
|
||||
constructor(text) {
|
||||
this.value = text;
|
||||
}
|
||||
},
|
||||
});
|
||||
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
|
||||
restoreHost();
|
||||
|
||||
const PAGE = `page Home {
|
||||
view {
|
||||
<div>hello</div>
|
||||
}
|
||||
functions {
|
||||
function go() {}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
test("a markup offset is inside a view block", () => {
|
||||
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("<div")), true);
|
||||
});
|
||||
|
||||
test("a functions-block offset is not inside a view block", () => {
|
||||
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false);
|
||||
});
|
||||
|
||||
test("provideCompletionItems returns empty array when inside view block", () => {
|
||||
const document = {
|
||||
getText() {
|
||||
return PAGE;
|
||||
},
|
||||
offsetAt() {
|
||||
return PAGE.indexOf("<div");
|
||||
},
|
||||
lineAt() {
|
||||
return { text: "<div>hello</div>" };
|
||||
},
|
||||
fileName: "test.wrn",
|
||||
};
|
||||
const position = { line: 2, character: 4 };
|
||||
|
||||
const result = provideCompletionItems(document, position);
|
||||
assert.equal(Array.isArray(result), true);
|
||||
assert.equal(result.length, 0);
|
||||
});
|
||||
|
||||
test("provideCompletionItems returns non-empty array when inside functions block", () => {
|
||||
const document = {
|
||||
getText() {
|
||||
return PAGE;
|
||||
},
|
||||
offsetAt() {
|
||||
return PAGE.indexOf("function go");
|
||||
},
|
||||
lineAt() {
|
||||
return { text: " function go() {}" };
|
||||
},
|
||||
fileName: "test.wrn",
|
||||
};
|
||||
const position = { line: 5, character: 4 };
|
||||
|
||||
const result = provideCompletionItems(document, position);
|
||||
assert.equal(Array.isArray(result), true);
|
||||
assert(result.length > 0, "should return non-empty completions outside view block");
|
||||
});
|
||||
@@ -2,17 +2,13 @@
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const Module = require("node:module");
|
||||
const { installVsCodeHost } = require("./vscode-host.js");
|
||||
|
||||
// These extraction helpers are pure, but their module also registers VS Code
|
||||
// providers at runtime. Supply a minimal host shim for unit tests.
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") return {};
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const restoreHost = installVsCodeHost({});
|
||||
const { extractRouteParams, extractStates } = require("../src/completion");
|
||||
Module._load = originalLoad;
|
||||
restoreHost();
|
||||
|
||||
test("extracts dynamic route params from filename", () => {
|
||||
const document = {
|
||||
|
||||
@@ -2,30 +2,24 @@
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const Module = require("node:module");
|
||||
const { installVsCodeHost } = require("./vscode-host.js");
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") {
|
||||
return {
|
||||
Diagnostic: class Diagnostic {
|
||||
constructor(range, message, severity) {
|
||||
this.range = range;
|
||||
this.message = message;
|
||||
this.severity = severity;
|
||||
}
|
||||
},
|
||||
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
||||
Range: class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const restoreHost = installVsCodeHost({
|
||||
Diagnostic: class Diagnostic {
|
||||
constructor(range, message, severity) {
|
||||
this.range = range;
|
||||
this.message = message;
|
||||
this.severity = severity;
|
||||
}
|
||||
},
|
||||
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
||||
Range: class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
},
|
||||
});
|
||||
const {
|
||||
findTopLevelDeclaration,
|
||||
maskLeadingTrivia,
|
||||
@@ -34,7 +28,7 @@ const {
|
||||
validateLayoutUsage,
|
||||
validateRootMembers,
|
||||
} = require("../src/diagnostics");
|
||||
Module._load = originalLoad;
|
||||
restoreHost();
|
||||
|
||||
function mockDocument() {
|
||||
return {
|
||||
|
||||
@@ -20,6 +20,20 @@ test("preserves nested prop defaults while formatting", () => {
|
||||
assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2 }), formatted);
|
||||
});
|
||||
|
||||
test("formats large typed JSON prop defaults with the bundled formatter", () => {
|
||||
const source = `component Catalog {
|
||||
props {
|
||||
categories: unknown[] = [{"label":"Platform & Core","apps":[{"label":"Home","href":"/home"},{"label":"SSO","href":"/sso"}]},{"label":"Sales","apps":[{"label":"CRM","href":"/crm"}]}]
|
||||
}
|
||||
view { <div></div> }
|
||||
}`;
|
||||
const formatted = formatWrn(source, { insertSpaces: true, tabSize: 2, printWidth: 70 });
|
||||
|
||||
assert.match(formatted, /categories: unknown\[\] = \[\n {6}\{/);
|
||||
assert.match(formatted, /"apps": \[\n {10}\{/);
|
||||
assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2, printWidth: 70 }), formatted);
|
||||
});
|
||||
|
||||
test("formats native JSON state values with readable indentation", () => {
|
||||
const source = `component Footer {
|
||||
state footerItems = [{"label":"Accessibility","href":"/accessibility","value":"accessibility"},{"label":"Privacy","href":"/privacy","value":"privacy"}]
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
const grammarPath = join(__dirname, "../syntaxes/wrn.tmLanguage.json");
|
||||
const grammar = JSON.parse(readFileSync(grammarPath, "utf8"));
|
||||
|
||||
test("the grammar knows the apis block", () => {
|
||||
assert.ok(grammar.repository["apis-block"], "apis should appear as a block keyword");
|
||||
});
|
||||
|
||||
test("client keeps its highlighting where it is still valid", () => {
|
||||
// client state {}, runtime = "client", and client function all survive.
|
||||
// Only the client {} data block was removed.
|
||||
assert.match(JSON.stringify(grammar), /client/, "client must still be matched");
|
||||
assert.match(
|
||||
JSON.stringify(grammar),
|
||||
/shared/,
|
||||
"the shared function modifier must still be matched",
|
||||
);
|
||||
});
|
||||
|
||||
// --- Structural assertions: these fail if the rule they name is deleted, ---
|
||||
// --- even though the substrings "apis"/"client" remain elsewhere in the ---
|
||||
// --- file (e.g. inside unrelated pattern names or comments). ---
|
||||
|
||||
/**
|
||||
* vscode-textmate is not a dependency of this repo (confirmed via
|
||||
* `require.resolve`), so real tokenization is unavailable here. Instead we
|
||||
* pull each rule's own `begin`/`match` regex out of the parsed grammar
|
||||
* object and exercise it directly with the JS regex engine. The grammar's
|
||||
* patterns use only `\b`, character classes, and `(?<=...)` lookbehind --
|
||||
* all supported by native JS regexes -- so this is a faithful proxy for
|
||||
* "does this specific rule match this specific source line" without
|
||||
* needing an Oniguruma-backed tokenizer.
|
||||
*/
|
||||
function ruleByTopLevelBlockName(name) {
|
||||
const entry = grammar.repository[name];
|
||||
assert.ok(entry, `repository.${name} should exist`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function topLevelBlockIncludes(name) {
|
||||
return grammar.repository.blocks.patterns.some((p) => p.include === `#${name}`);
|
||||
}
|
||||
|
||||
test("blocks includes the apis-block pattern", () => {
|
||||
assert.ok(topLevelBlockIncludes("apis-block"), "#blocks must include #apis-block");
|
||||
});
|
||||
|
||||
test("the apis-block begin pattern matches the container keyword", () => {
|
||||
const rule = ruleByTopLevelBlockName("apis-block");
|
||||
const beginRe = new RegExp(rule.begin);
|
||||
assert.ok(beginRe.test("apis {"), "apis-block begin should match 'apis {'");
|
||||
assert.ok(!beginRe.test("ssr {"), "apis-block begin must not match 'ssr {'");
|
||||
assert.ok(!beginRe.test("client {"), "apis-block begin must not match 'client {'");
|
||||
});
|
||||
|
||||
test("the apis-block declares an entry pattern for <name> <METHOD> <path>", () => {
|
||||
const rule = ruleByTopLevelBlockName("apis-block");
|
||||
const entryPattern = (rule.patterns || []).find(
|
||||
(p) => p.name === "apis-entry" || (p.begin && /GET\|POST/.test(p.begin)),
|
||||
);
|
||||
assert.ok(entryPattern, "apis-block should contain an entry declaration pattern");
|
||||
const entryRe = new RegExp(entryPattern.begin || entryPattern.match);
|
||||
assert.ok(
|
||||
entryRe.test("searchUsers POST /api/users {"),
|
||||
"the entry pattern should match '<name> <METHOD> <path> {'",
|
||||
);
|
||||
});
|
||||
|
||||
test("the ssr/client data-block pattern (mode-block) is gone", () => {
|
||||
assert.strictEqual(
|
||||
grammar.repository["mode-block"],
|
||||
undefined,
|
||||
"mode-block (the removed ssr {}/client {} data-block rule) must be deleted from the repository",
|
||||
);
|
||||
assert.ok(
|
||||
!topLevelBlockIncludes("mode-block"),
|
||||
"#blocks must no longer include the removed #mode-block pattern",
|
||||
);
|
||||
});
|
||||
|
||||
// --- Regression coverage: constructs that must NOT be broken by this change ---
|
||||
|
||||
test("REGRESSION: client state {} still highlights via grouped-state-block", () => {
|
||||
const rule = ruleByTopLevelBlockName("grouped-state-block");
|
||||
const beginRe = new RegExp(rule.begin);
|
||||
assert.ok(
|
||||
beginRe.test("client state {"),
|
||||
"grouped-state-block must still match 'client state {'",
|
||||
);
|
||||
});
|
||||
|
||||
test('REGRESSION: runtime = "client" still highlights via execution-decl', () => {
|
||||
const rule = ruleByTopLevelBlockName("execution-decl");
|
||||
const beginRe = new RegExp(rule.begin);
|
||||
assert.ok(
|
||||
beginRe.test('runtime = "client"'),
|
||||
"execution-decl must still match 'runtime = \"client\"'",
|
||||
);
|
||||
});
|
||||
|
||||
test("REGRESSION: the client function modifier still highlights via functions-block", () => {
|
||||
const rule = ruleByTopLevelBlockName("functions-block");
|
||||
const matchRe = new RegExp(
|
||||
rule.patterns.find(
|
||||
(p) => p.captures && p.captures["1"]?.name === "storage.modifier.runtime.wrn",
|
||||
).match,
|
||||
);
|
||||
assert.ok(
|
||||
matchRe.test("client async function go(): Promise<void> {"),
|
||||
"functions-block must still recognize the 'client' runtime modifier on a function",
|
||||
);
|
||||
});
|
||||
|
||||
test("REGRESSION: the client function modifier also highlights via v060-keywords", () => {
|
||||
const rule = ruleByTopLevelBlockName("v060-keywords");
|
||||
const modifierPattern = rule.patterns.find((p) => p.name === "storage.modifier.runtime.wrn");
|
||||
assert.ok(modifierPattern, "v060-keywords should have a storage.modifier.runtime.wrn rule");
|
||||
const matchRe = new RegExp(modifierPattern.match);
|
||||
assert.ok(
|
||||
matchRe.test("client function go"),
|
||||
"v060-keywords must still recognize 'client' before 'function'",
|
||||
);
|
||||
assert.ok(
|
||||
matchRe.test("client state"),
|
||||
"v060-keywords must still recognize 'client' before 'state'",
|
||||
);
|
||||
});
|
||||
@@ -41,6 +41,51 @@ function run(messages) {
|
||||
return parseResponses(result.stdout || Buffer.alloc(0));
|
||||
}
|
||||
|
||||
test("bundled language server accepts translated attributes and form statement handlers", () => {
|
||||
const uri = "file:///tmp/wrnexus-translated-contact.wrn";
|
||||
const valid = `page Contact {
|
||||
view {
|
||||
<div data-component="PageHeader" eyebrow="{t:contact.eyebrow}" title="{t:contact.title}" description="{t:contact.description}"></div>
|
||||
<form @wrn:success="event.currentTarget.reset(); toast.success('Sent')"></form>
|
||||
}
|
||||
}`;
|
||||
const responses = run([
|
||||
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { rootPath: root } },
|
||||
{ jsonrpc: "2.0", method: "initialized", params: {} },
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "textDocument/didOpen",
|
||||
params: { textDocument: { uri, languageId: "wrn", version: 1, text: valid } },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "textDocument/didChange",
|
||||
params: {
|
||||
textDocument: { uri, version: 2 },
|
||||
contentChanges: [
|
||||
{ text: valid.replace("{t:contact.title}", "{t:contact.title} {missingValue}") },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "textDocument/didSave", params: { textDocument: { uri } } },
|
||||
{ jsonrpc: "2.0", id: 2, method: "shutdown", params: {} },
|
||||
{ jsonrpc: "2.0", method: "exit", params: {} },
|
||||
]);
|
||||
const publications = responses.filter(
|
||||
(item) => item.method === "textDocument/publishDiagnostics",
|
||||
);
|
||||
assert.equal(publications.length, 2);
|
||||
assert.deepEqual(
|
||||
publications[0].params.diagnostics.filter((item) => item.source === "wrnexus-types"),
|
||||
[],
|
||||
);
|
||||
const errors = publications[1].params.diagnostics.filter(
|
||||
(item) => item.source === "wrnexus-types",
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0].message, /missingValue/);
|
||||
});
|
||||
|
||||
test("language server starts under Node and keeps official typed components clean", () => {
|
||||
const files = [
|
||||
path.join(root, "packages", "ui", "components", "AuthForm.wrn"),
|
||||
|
||||
@@ -113,6 +113,21 @@ try {
|
||||
readFileSync(join(root, rel), "utf8");
|
||||
ok(`Marketplace document exists: ${rel}`);
|
||||
}
|
||||
|
||||
const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"];
|
||||
emmetLanguages?.wrn === "html"
|
||||
? ok("Emmet is mapped for wrn documents")
|
||||
: bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`);
|
||||
|
||||
const autoClose =
|
||||
manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"];
|
||||
autoClose?.type === "boolean" && autoClose?.default === true
|
||||
? ok("auto-closing tags setting is contributed")
|
||||
: bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`);
|
||||
|
||||
manifest.dependencies?.["vscode-html-languageservice"]
|
||||
? ok("HTML language service ships as a runtime dependency")
|
||||
: bad("HTML language service ships as a runtime dependency");
|
||||
} catch (e) {
|
||||
bad("Marketplace metadata", e.message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Supply a stub `vscode` host so extension sources can be unit tested.
|
||||
*
|
||||
* These files run under `node --test` (see the package's test script), where
|
||||
* patching `Module._load` is enough. A bare `bun test` from the repository
|
||||
* root also picks them up by filename, and Bun resolves `require` through its
|
||||
* own resolver without consulting `Module._load` -- so under Bun the same
|
||||
* files failed with "Cannot find package 'vscode'". Registering a virtual
|
||||
* module covers that case, leaving one shim that works under both runners.
|
||||
*
|
||||
* Returns a function restoring the original loader.
|
||||
*/
|
||||
function installVsCodeHost(stub) {
|
||||
const Module = require("node:module");
|
||||
|
||||
if (typeof Bun !== "undefined") {
|
||||
require("bun").plugin({
|
||||
name: "vscode-host-stub",
|
||||
setup(build) {
|
||||
build.module("vscode", () => ({ exports: stub, loader: "object" }));
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") return stub;
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
return () => {
|
||||
Module._load = originalLoad;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installVsCodeHost };
|
||||
@@ -0,0 +1,295 @@
|
||||
# WrNexus app - instructions for AI coding assistants
|
||||
|
||||
This is a **WrNexus** app. When creating or editing pages, components, API routes,
|
||||
or features, follow the framework conventions below. WrNexus is private and not in
|
||||
your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns.
|
||||
|
||||
# WrNexus
|
||||
|
||||
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||
> assumptions.
|
||||
|
||||
## Golden rules
|
||||
|
||||
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||
on the server and hydrate automatically — you never write client-side JS islands.
|
||||
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
app/
|
||||
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||
middleware/ *.ts → export default async (ctx, next) => next()
|
||||
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||
locales/ *.json → i18n messages per language
|
||||
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||
styles/ global.css → Tailwind (default) or plain CSS
|
||||
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||
public/ → static assets served at /
|
||||
```
|
||||
|
||||
## `.wrn` page
|
||||
|
||||
```wrn
|
||||
page Home {
|
||||
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||
|
||||
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||
|
||||
seo {
|
||||
title = "Home"
|
||||
description = "..."
|
||||
canonical = "/"
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>Hello</h1>
|
||||
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||
<button @click="count++">Increment</button>
|
||||
<div data-component="counter" start="5" label="Clicks"></div>
|
||||
}
|
||||
|
||||
style {
|
||||
h1 { color: var(--wrn-color-text); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `.wrn` component
|
||||
|
||||
```wrn
|
||||
component Counter {
|
||||
props { // props come from mount attributes; each is coerced to the
|
||||
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||
label = "Count"
|
||||
}
|
||||
state count = start // state may reference props
|
||||
view {
|
||||
<button @click="count++">{label}: {count}</button>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||
Components render on the server with their props, then hydrate — no per-component JS.
|
||||
|
||||
## The `view { }` block (plain HTML + a few directives)
|
||||
|
||||
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||
- Theme: any element with `data-wrn-theme-toggle` toggles light/dark; `data-wrn-theme-set="dark"` sets it.
|
||||
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||
|
||||
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||
|
||||
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||
|
||||
```wrn
|
||||
page Admin {
|
||||
layout = "dashboard"
|
||||
|
||||
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||
ssr {
|
||||
api rows GET /api/contacts { return contacts }
|
||||
}
|
||||
|
||||
view {
|
||||
<table>
|
||||
<tbody>
|
||||
{#each rows as r, i}
|
||||
<tr>
|
||||
<td>#{i}</td>
|
||||
<td>{r.name}</td>
|
||||
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||
</tr>
|
||||
{:empty}
|
||||
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The matching API returns the array under a key the `ssr` block reads:
|
||||
|
||||
```ts
|
||||
// app/api/contacts.ts → GET /api/contacts
|
||||
import { getDb } from "@wrnexus/db";
|
||||
export const GET = async () => {
|
||||
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||
};
|
||||
```
|
||||
|
||||
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||
|
||||
## API routes (`app/api/*.ts`)
|
||||
|
||||
```ts
|
||||
// app/api/users/list.ts → GET /api/users/list
|
||||
import { getDb } from "@wrnexus/db";
|
||||
|
||||
export const GET = async (ctx) => {
|
||||
return Response.json({ users: await ListUsers(getDb()) });
|
||||
};
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
const body = await ctx.req.json();
|
||||
return Response.json({ ok: true, body }, { status: 201 });
|
||||
};
|
||||
```
|
||||
|
||||
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||
|
||||
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||
|
||||
```ts
|
||||
import { redirectToLogin } from "@wrnexus/helpers";
|
||||
|
||||
return redirectToLogin(ctx, "/login", {
|
||||
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||
});
|
||||
```
|
||||
|
||||
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||
|
||||
## Middleware & realtime
|
||||
|
||||
```ts
|
||||
// app/middleware/logger.ts
|
||||
export default async function logger(ctx, next) {
|
||||
console.log(ctx.req.method, ctx.url.pathname);
|
||||
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||
import { defineRoom } from "@wrnexus/core";
|
||||
export default defineRoom({
|
||||
onConnect(client) {
|
||||
client.send({ type: "system", text: "connected" });
|
||||
},
|
||||
onMessage(client, msg) {
|
||||
client.room.broadcast({ type: "message", data: msg });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||
|
||||
## Config (`wrnexus.config.ts`)
|
||||
|
||||
```ts
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
const config: AppConfig = {
|
||||
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||
styles: {
|
||||
entry: "app/styles/global.css",
|
||||
process: async ({ entryPath, mode }) => /* Tailwind */ "",
|
||||
},
|
||||
fonts: {
|
||||
sans: '"Inter", system-ui, sans-serif',
|
||||
google: [{ family: "Inter", weights: [400, 600] }],
|
||||
},
|
||||
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||
i18n: { default: "en", locales: ["en", "es"] },
|
||||
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||
};
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Database (`@wrnexus/db`)
|
||||
|
||||
```ts
|
||||
// app/db/schema.ts
|
||||
import { v, table } from "@wrnexus/db";
|
||||
export const users = table("users", {
|
||||
id: v.id(),
|
||||
name: v.string(),
|
||||
email: v.string().unique(),
|
||||
createdAt: v.timestamp(),
|
||||
});
|
||||
```
|
||||
|
||||
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||
|
||||
## Validation (`@wrnexus/validation`)
|
||||
|
||||
```ts
|
||||
// app/schemas/login.ts
|
||||
import { v } from "@wrnexus/validation";
|
||||
export default v.object({
|
||||
email: v.string().email(),
|
||||
password: v.string().min(8),
|
||||
});
|
||||
```
|
||||
|
||||
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation connected automatically).
|
||||
|
||||
## AI / LLM (`@wrnexus/ai`)
|
||||
|
||||
```ts
|
||||
// app/api/ai.ts
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||
export const POST = async (ctx) => {
|
||||
const { prompt } = await ctx.req.json();
|
||||
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||
};
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
wrnexus dev . # dev server + HMR
|
||||
wrnexus build . # production build → dist/server.js
|
||||
bun dist/server.js # run the production server (or npm start)
|
||||
wrnexus create <name> # scaffold a new app
|
||||
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||
wrnexus generate component <name> | api <path> | schema <name>
|
||||
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||
wrnexus eject <component> # copy a WrNexus UI component's .wrn into app/components to customize
|
||||
```
|
||||
|
||||
## When asked to "create a page/component/feature"
|
||||
|
||||
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wrn-*)`), or `style { }`.
|
||||
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineEndpoint } from "@wrnexus/core";
|
||||
import { SearchDirectorySchema } from "../schemas/search-directory.ts";
|
||||
|
||||
interface DirectoryUser {
|
||||
name: string;
|
||||
designation: string;
|
||||
}
|
||||
|
||||
const ALL: DirectoryUser[] = [
|
||||
{ name: "Ajay", designation: "UI" },
|
||||
{ name: "Asha", designation: "Backend" },
|
||||
{ name: "Chen", designation: "UI" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Schema-typed handler: input resolves to `{ name?: string }` from
|
||||
* SearchDirectorySchema, so the block's request shape is checked for real
|
||||
* (not against `unknown`, which the untyped-handler shape resolved to).
|
||||
*/
|
||||
export const POST = defineEndpoint<{ name?: string }, { users: DirectoryUser[] }>({
|
||||
input: SearchDirectorySchema,
|
||||
description: "Case-insensitive substring search over the demo directory by name.",
|
||||
handler(input) {
|
||||
const needle = String(input.name ?? "").toLowerCase();
|
||||
return { users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
page ApiBlockDemo {
|
||||
state nameFilter = "a"
|
||||
state found = ""
|
||||
state failed = ""
|
||||
|
||||
apis {
|
||||
searchDirectory POST /api/directory {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.data.users
|
||||
}
|
||||
|
||||
error {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load server serverSearch {
|
||||
const users = await api.searchDirectory({ name: "a" })
|
||||
return { names: users.map((user) => user.name).join(", ") }
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function search(): Promise<void> {
|
||||
const users = await api.searchDirectory({ name: nameFilter })
|
||||
found = users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<main>
|
||||
<button @click="search()">Search</button>
|
||||
<p class="found" data-text="found">{found}</p>
|
||||
<p class="failed" data-text="failed">{failed}</p>
|
||||
<Async source="serverSearch">
|
||||
<Loading><p class="server-found">Loading…</p></Loading>
|
||||
<Success data="serverSearch"><p class="server-found">{serverSearch.names}</p></Success>
|
||||
<Error error="error"><p class="server-found">Failed: {error.message}</p></Error>
|
||||
</Async>
|
||||
<p class="bound" api="searchDirectory({ name: 'a' })">loading</p>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Viewer from "@wrnexus/viewer/components/Viewer.wrn"
|
||||
|
||||
page FilePreviewPage {
|
||||
layout = "public"
|
||||
render = "hybrid"
|
||||
hydrate = "load"
|
||||
|
||||
view {
|
||||
<main class="file-preview-page">
|
||||
<Viewer src="/viewer/sample.pdf" name="sample.pdf" layout="page" />
|
||||
</main>
|
||||
}
|
||||
style {
|
||||
.file-preview-page { display: flex; flex-direction: column; height: calc(100vh - 4rem); padding: 1rem; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Viewer from "@wrnexus/viewer/components/Viewer.wrn"
|
||||
|
||||
page FileViewerDemo {
|
||||
layout = "public"
|
||||
render = "hybrid"
|
||||
hydrate = "load"
|
||||
|
||||
view {
|
||||
<main class="file-viewer-demo">
|
||||
<h1>File previews</h1>
|
||||
<h2>Browse several files</h2>
|
||||
<Viewer files={[{src: '/viewer/sample.png', name: 'sample.png'}, {src: '/viewer/sample.html', name: 'sample.html'}, {src: '/viewer/sample.wav', name: 'sample.wav'}, {src: '/viewer/sample.docx', name: 'sample.docx'}]} />
|
||||
<h2>Rich PDF canvas</h2>
|
||||
<Viewer src="/viewer/sample.pdf" name="rich-sample.pdf" mime="application/pdf" richPdf={true} />
|
||||
<p>Images, documents, media, tables, trees, vectors, and archives share the app theme and preview controls.</p>
|
||||
<h2>Image</h2>
|
||||
<Viewer src="/viewer/sample.png" name="sample.png" />
|
||||
<h2>Document</h2>
|
||||
<Viewer src="/viewer/sample.pdf" name="sample.pdf" />
|
||||
<h2>Video</h2>
|
||||
<Viewer src="/viewer/sample.webm" name="sample.webm" />
|
||||
<h2>Open a preview over the page</h2>
|
||||
<p>Any element with <code>data-viewer-open</code> opens the file in a dialog.</p>
|
||||
<p>
|
||||
<button type="button" data-viewer-open data-src="/viewer/sample.webm" data-name="sample.webm">Preview the video</button>
|
||||
<button type="button" data-viewer-open data-src="/viewer/sample.pptx" data-name="sample.pptx">Preview the deck</button>
|
||||
</p>
|
||||
<h2>Covering the page</h2>
|
||||
<p>A viewer can mount already expanded, with a Close control that returns it inline.</p>
|
||||
<Viewer src="/viewer/sample.png" name="expanded.png" fullScreen={false} />
|
||||
<h2>Audio</h2>
|
||||
<Viewer src="/viewer/sample.wav" name="sample.wav" />
|
||||
<h2>Structured data</h2>
|
||||
<Viewer src="/viewer/sample.json" name="sample.json" />
|
||||
<Viewer src="/viewer/sample.xml" name="sample.xml" />
|
||||
<h2>Spreadsheet</h2>
|
||||
<Viewer src="/viewer/sample.csv" name="sample.csv" />
|
||||
<Viewer src="/viewer/sample.xlsx" name="sample.xlsx" />
|
||||
<h2>Vector graphic</h2>
|
||||
<Viewer src="/viewer/sample.svg" name="sample.svg" />
|
||||
<h2>Archive</h2>
|
||||
<Viewer src="/viewer/sample.tar" name="sample.tar" />
|
||||
<Viewer src="/viewer/sample.zip" name="sample.zip" />
|
||||
<h2>HTML preview and source</h2>
|
||||
<Viewer src="/viewer/sample.html" name="sample.html" />
|
||||
<h2>From uploader storage</h2>
|
||||
<p>A public object serves itself; a private one resolves to a short-lived signed link.</p>
|
||||
<Viewer key="brand-logo.png" store="media" />
|
||||
<Viewer key="quarterly-report.pdf" store="reports" />
|
||||
<Viewer key="regional-figures.csv" store="reports" />
|
||||
<h2>Office documents</h2>
|
||||
<p>Presentations, OpenDocument files and RTF read in the browser; a converter, where one is installed, is preferred for page-faithful output.</p>
|
||||
<Viewer src="/viewer/sample.pptx" name="sample.pptx" />
|
||||
<Viewer src="/viewer/sample.odt" name="sample.odt" />
|
||||
<Viewer src="/viewer/sample.ods" name="sample.ods" />
|
||||
<Viewer src="/viewer/sample.rtf" name="sample.rtf" />
|
||||
<h2>Optional parser or server-side converter</h2>
|
||||
<Viewer src="/viewer/sample.docx" name="sample.docx" />
|
||||
<h2>Unsupported file</h2>
|
||||
<Viewer src="/viewer/sample.bin" name="sample.bin" />
|
||||
</main>
|
||||
}
|
||||
style {
|
||||
.file-viewer-demo { display: grid; gap: 1rem; max-width: 64rem; margin: 2rem auto; padding: 1rem; }
|
||||
}
|
||||
}
|
||||
@@ -18,37 +18,32 @@ page Hello {
|
||||
canonical = "/hello"
|
||||
}
|
||||
|
||||
// SSR data bindings run on the server before the HTML is sent.
|
||||
// The API itself lives in app/api/users/ssr.ts; this block only calls it
|
||||
// and renders the response into HTML.
|
||||
ssr {
|
||||
functions {
|
||||
function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
// API calls used by this page. `ssrUsers` is bound in the initial render
|
||||
// (its endpoint lives in app/api/users/ssr.ts); `csrUsers` is bound after
|
||||
// hydration in the browser (app/api/users/csr.ts). Both just call the same
|
||||
// users endpoint shape, so the response handling looks the same.
|
||||
apis {
|
||||
ssrUsers GET /api/users/ssr {
|
||||
response {
|
||||
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
|
||||
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
|
||||
session.set("lastHelloVisit", visits)
|
||||
return `${userNames(data.users)} - visit ${visits}`
|
||||
}
|
||||
}
|
||||
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
|
||||
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
|
||||
session.set("lastHelloVisit", visits)
|
||||
return `${userNames(users)} - visit ${visits}`
|
||||
csrUsers GET /api/users/csr {
|
||||
response {
|
||||
const label = localStorage.get("wrnexus.label") ?? "browser"
|
||||
session.set("lastClientLabel", label)
|
||||
return `${userNames(data.users)} - ${label}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client data bindings hydrate after the first paint. The browser only sees
|
||||
// an opaque data-wrnexus-csr id; WrNexus calls app/api/users/csr.ts on the server.
|
||||
client {
|
||||
functions {
|
||||
function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
api csrUsers GET /api/users/csr {
|
||||
const label = localStorage.get("wrnexus.label") ?? "browser"
|
||||
session.set("lastClientLabel", label)
|
||||
return `${userNames(users)} - ${label}`
|
||||
functions {
|
||||
shared function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { defineJob } from "@wrnexus/queue";
|
||||
import { defineQueue } from "@wrnexus/queue";
|
||||
|
||||
export interface WelcomeEmailPayload {
|
||||
userId: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default defineJob<WelcomeEmailPayload>({
|
||||
/*
|
||||
* A file under app/queues must default-export defineQueue(...) -- the loader
|
||||
* refuses anything else rather than registering a queue that would never run.
|
||||
* This example used the older defineJob() shape and so failed to load, which
|
||||
* took the whole example app down with it.
|
||||
*/
|
||||
export default defineQueue({
|
||||
name: "welcome-email",
|
||||
async run(job) {
|
||||
console.log(`Welcome email queued for ${job.data.email}`);
|
||||
jobs: {
|
||||
send: {
|
||||
validate: (data: unknown): data is WelcomeEmailPayload =>
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
Number.isInteger((data as { userId?: unknown }).userId) &&
|
||||
typeof (data as { email?: unknown }).email === "string",
|
||||
run: async ({ email }: WelcomeEmailPayload) => {
|
||||
console.log(`Welcome email queued for ${email}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -4,10 +4,13 @@
|
||||
export interface Routes {
|
||||
"/": Record<string, never>;
|
||||
"/about": Record<string, never>;
|
||||
"/api-block-demo": Record<string, never>;
|
||||
"/async-data": Record<string, never>;
|
||||
"/chat": Record<string, never>;
|
||||
"/client-only": Record<string, never>;
|
||||
"/dashboard": Record<string, never>;
|
||||
"/file-preview": Record<string, never>;
|
||||
"/file-viewer": Record<string, never>;
|
||||
"/hello": Record<string, never>;
|
||||
"/island-demo": Record<string, never>;
|
||||
"/language-tools": Record<string, never>;
|
||||
@@ -27,10 +30,13 @@ export interface Routes {
|
||||
export interface RouteNames {
|
||||
"index": "/";
|
||||
"about": "/about";
|
||||
"api.block.demo": "/api-block-demo";
|
||||
"async.data": "/async-data";
|
||||
"chat": "/chat";
|
||||
"client.only": "/client-only";
|
||||
"dashboard": "/dashboard";
|
||||
"file.preview": "/file-preview";
|
||||
"file.viewer": "/file-viewer";
|
||||
"hello": "/hello";
|
||||
"island.demo": "/island-demo";
|
||||
"language.tools": "/language-tools";
|
||||
@@ -119,10 +125,13 @@ export function route<N extends RouteName>(
|
||||
const paths: Record<RouteName, RoutePath> = {
|
||||
"index": "/",
|
||||
"about": "/about",
|
||||
"api.block.demo": "/api-block-demo",
|
||||
"async.data": "/async-data",
|
||||
"chat": "/chat",
|
||||
"client.only": "/client-only",
|
||||
"dashboard": "/dashboard",
|
||||
"file.preview": "/file-preview",
|
||||
"file.viewer": "/file-viewer",
|
||||
"hello": "/hello",
|
||||
"island.demo": "/island-demo",
|
||||
"language.tools": "/language-tools",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { v } from "@wrnexus/validation";
|
||||
|
||||
export const SearchDirectorySchema = v.object({
|
||||
name: v.string().trim().optional(),
|
||||
});
|
||||
@@ -45,13 +45,7 @@ body {
|
||||
Roboto,
|
||||
sans-serif;
|
||||
color: var(--text);
|
||||
/* Tinted from the primary token so the wash follows the theme too. */
|
||||
background: radial-gradient(
|
||||
1200px 600px at 50% -10%,
|
||||
color-mix(in srgb, var(--wrn-color-primary) 18%, var(--bg)) 0%,
|
||||
var(--bg) 60%
|
||||
);
|
||||
background-color: var(--bg);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
#app {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
|
||||
//
|
||||
// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not
|
||||
// wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being
|
||||
// checked; this file is compiled and checked normally by the project's own tsc.
|
||||
export type __wrn_api_check_1fljm5i_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
|
||||
export {};
|
||||
+13
-2
@@ -13,8 +13,8 @@ declare namespace WRNexusGenerated {
|
||||
: never;
|
||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||
type RouteName = "about" | "api.block.demo" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||
type ApiRoute = "/api/accounts" | "/api/directory" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
|
||||
@@ -29,6 +29,7 @@ declare namespace WRNexusGenerated {
|
||||
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
||||
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
||||
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
||||
"/api/directory": { POST: ApiContract<typeof import("../api/directory.ts")["POST"]> };
|
||||
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
|
||||
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
|
||||
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
||||
@@ -57,4 +58,14 @@ declare namespace WRNexusGenerated {
|
||||
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
||||
}
|
||||
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
||||
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||
? true
|
||||
: [Actual] extends [Expected]
|
||||
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||
? true
|
||||
: false
|
||||
: false;
|
||||
type __wrn_expect_true<T extends true> = T;
|
||||
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "basic-app",
|
||||
"version": "0.8.0",
|
||||
"wrnexus": {
|
||||
"version": "0.9.0"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,9 +19,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
"@wrnexus/ui": "workspace:*",
|
||||
"@wrnexus/uploader": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/viewer": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/test": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# WrNexus
|
||||
|
||||
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||
> assumptions.
|
||||
|
||||
## Golden rules
|
||||
|
||||
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||
on the server and hydrate automatically — you never write client-side JS islands.
|
||||
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
app/
|
||||
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||
middleware/ *.ts → export default async (ctx, next) => next()
|
||||
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||
locales/ *.json → i18n messages per language
|
||||
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||
styles/ global.css → Tailwind (default) or plain CSS
|
||||
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||
public/ → static assets served at /
|
||||
```
|
||||
|
||||
## `.wrn` page
|
||||
|
||||
```wrn
|
||||
page Home {
|
||||
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||
|
||||
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||
|
||||
seo {
|
||||
title = "Home"
|
||||
description = "..."
|
||||
canonical = "/"
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>Hello</h1>
|
||||
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||
<button @click="count++">Increment</button>
|
||||
<div data-component="counter" start="5" label="Clicks"></div>
|
||||
}
|
||||
|
||||
style {
|
||||
h1 { color: var(--wrn-color-text); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `.wrn` component
|
||||
|
||||
```wrn
|
||||
component Counter {
|
||||
props { // props come from mount attributes; each is coerced to the
|
||||
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||
label = "Count"
|
||||
}
|
||||
state count = start // state may reference props
|
||||
view {
|
||||
<button @click="count++">{label}: {count}</button>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||
Components render on the server with their props, then hydrate — no per-component JS.
|
||||
|
||||
## The `view { }` block (plain HTML + a few directives)
|
||||
|
||||
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||
- Theme: any element with `data-wrn-theme-toggle` toggles light/dark; `data-wrn-theme-set="dark"` sets it.
|
||||
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||
|
||||
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||
|
||||
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||
|
||||
```wrn
|
||||
page Admin {
|
||||
layout = "dashboard"
|
||||
|
||||
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||
ssr {
|
||||
api rows GET /api/contacts { return contacts }
|
||||
}
|
||||
|
||||
view {
|
||||
<table>
|
||||
<tbody>
|
||||
{#each rows as r, i}
|
||||
<tr>
|
||||
<td>#{i}</td>
|
||||
<td>{r.name}</td>
|
||||
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||
</tr>
|
||||
{:empty}
|
||||
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The matching API returns the array under a key the `ssr` block reads:
|
||||
|
||||
```ts
|
||||
// app/api/contacts.ts → GET /api/contacts
|
||||
import { getDb } from "@wrnexus/db";
|
||||
export const GET = async () => {
|
||||
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||
};
|
||||
```
|
||||
|
||||
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||
|
||||
## API routes (`app/api/*.ts`)
|
||||
|
||||
```ts
|
||||
// app/api/users/list.ts → GET /api/users/list
|
||||
import { getDb } from "@wrnexus/db";
|
||||
|
||||
export const GET = async (ctx) => {
|
||||
return Response.json({ users: await ListUsers(getDb()) });
|
||||
};
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
const body = await ctx.req.json();
|
||||
return Response.json({ ok: true, body }, { status: 201 });
|
||||
};
|
||||
```
|
||||
|
||||
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||
|
||||
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||
|
||||
```ts
|
||||
import { redirectToLogin } from "@wrnexus/helpers";
|
||||
|
||||
return redirectToLogin(ctx, "/login", {
|
||||
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||
});
|
||||
```
|
||||
|
||||
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||
|
||||
## Middleware & realtime
|
||||
|
||||
```ts
|
||||
// app/middleware/logger.ts
|
||||
export default async function logger(ctx, next) {
|
||||
console.log(ctx.req.method, ctx.url.pathname);
|
||||
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||
import { defineRoom } from "@wrnexus/core";
|
||||
export default defineRoom({
|
||||
onConnect(client) { client.send({ type: "system", text: "connected" }); },
|
||||
onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); },
|
||||
});
|
||||
```
|
||||
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||
|
||||
## Config (`wrnexus.config.ts`)
|
||||
|
||||
```ts
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
const config: AppConfig = {
|
||||
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||
styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" },
|
||||
fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] },
|
||||
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||
i18n: { default: "en", locales: ["en", "es"] },
|
||||
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||
};
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Database (`@wrnexus/db`)
|
||||
|
||||
```ts
|
||||
// app/db/schema.ts
|
||||
import { v, table } from "@wrnexus/db";
|
||||
export const users = table("users", {
|
||||
id: v.id(),
|
||||
name: v.string(),
|
||||
email: v.string().unique(),
|
||||
createdAt: v.timestamp(),
|
||||
});
|
||||
```
|
||||
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||
|
||||
## Validation (`@wrnexus/validation`)
|
||||
|
||||
```ts
|
||||
// app/schemas/login.ts
|
||||
import { v } from "@wrnexus/validation";
|
||||
export default v.object({
|
||||
email: v.string().email(),
|
||||
password: v.string().min(8),
|
||||
});
|
||||
```
|
||||
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation connected automatically).
|
||||
|
||||
## AI / LLM (`@wrnexus/ai`)
|
||||
|
||||
```ts
|
||||
// app/api/ai.ts
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||
export const POST = async (ctx) => {
|
||||
const { prompt } = await ctx.req.json();
|
||||
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||
};
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
wrnexus dev . # dev server + HMR
|
||||
wrnexus build . # production build → dist/server.js
|
||||
bun dist/server.js # run the production server (or npm start)
|
||||
wrnexus create <name> # scaffold a new app
|
||||
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||
wrnexus generate component <name> | api <path> | schema <name>
|
||||
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||
wrnexus eject <component> # copy a WrNexus UI component's .wrn into app/components to customize
|
||||
```
|
||||
|
||||
## When asked to "create a page/component/feature"
|
||||
|
||||
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wrn-*)`), or `style { }`.
|
||||
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
region,units,revenue
|
||||
"North, upper",120,15400.50
|
||||
"South ""central""",95,11200.00
|
||||
East,140,18990.75
|
||||
West,88,9400.25
|
||||
|
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<title>HTML source preview</title>
|
||||
<body><h1>HTML preview in a sandbox</h1><p>Use Source to inspect this document. Scripts and external resources are disabled.</p></body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "WRNexus file viewer",
|
||||
"readOnly": true,
|
||||
"features": ["image controls", "native PDF", "media playback", "source preview"]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}\f0\fs24 Rich Text Format\par\par This document is read in the browser with no dependency at all \u8212? RTF is text once the control words are gone.\par\par Accented text survives: Caf\'e9.}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><circle cx="60" cy="60" r="52" fill="none" stroke="currentColor" stroke-width="6"/><path d="M38 62l16 16 30-34" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 256 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<catalog>
|
||||
<product id="a-1"><name>Desk lamp</name><price currency="INR">1499</price></product>
|
||||
<product id="a-2"><name>Notebook</name><price currency="INR">249</price></product>
|
||||
</catalog>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
region,units,revenue
|
||||
"North, upper",120,15400.50
|
||||
"South ""central""",95,11200.00
|
||||
East,140,18990.75
|
||||
West,88,9400.25
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -2,6 +2,9 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
import { viewerPlugin } from "@wrnexus/viewer/plugin";
|
||||
import { localBinaryConverter, firstOf, sameOriginSource } from "@wrnexus/viewer/convert";
|
||||
import { uploadedConversionSource } from "@wrnexus/viewer/uploader";
|
||||
|
||||
/**
|
||||
* App configuration. Optional — delete this file and the app still runs.
|
||||
@@ -13,8 +16,38 @@ import type { AppConfig } from "@wrnexus/styles";
|
||||
* framework-level headers and optional CORS.
|
||||
*/
|
||||
const config: AppConfig = {
|
||||
frameworkBehaviour: 1,
|
||||
compatibilityDate: "2026-08-02",
|
||||
// Server-side previews for formats no browser renders (.docx, .psd, .mkv,
|
||||
// .zip). Each recipe needs its binary on the host — LibreOffice, ImageMagick,
|
||||
// ffmpeg, 7-Zip. A missing binary is not an error: that format simply keeps
|
||||
// its download card, so this is safe to leave enabled anywhere.
|
||||
plugins: [
|
||||
viewerPlugin({
|
||||
convert: {
|
||||
converters: [localBinaryConverter()],
|
||||
// Converted output inherits the access check of its input: only a valid
|
||||
// signed link opens an object, so a private report cannot be converted
|
||||
// into a public preview.
|
||||
resolveSource: firstOf(uploadedConversionSource(), sameOriginSource()),
|
||||
},
|
||||
uploads: {
|
||||
stores: ["media", "reports"],
|
||||
secret: process.env.VIEWER_FILE_SECRET ?? "example-app-development-secret-value",
|
||||
ttlMs: 5 * 60 * 1000,
|
||||
// DEMO ONLY. A real app checks the signed-in user against the object
|
||||
// here — this hook is the whole access control for private previews.
|
||||
authorize: () => true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
storage: {
|
||||
default: "media",
|
||||
stores: {
|
||||
media: { driver: "local", access: "public", dir: "uploads/public" },
|
||||
reports: { driver: "local", access: "private", dir: "uploads/private" },
|
||||
},
|
||||
},
|
||||
|
||||
head: [
|
||||
// --- Use a CSS framework via CDN (uncomment one) ---
|
||||
// Bootstrap:
|
||||
@@ -67,6 +100,17 @@ const config: AppConfig = {
|
||||
// Defaults already include CSP, COOP, X-Frame-Options, Trusted Types in
|
||||
// production, strong HSTS in production, nosniff, referrer policy, and a
|
||||
// restrictive Permissions-Policy. CORS is opt-in:
|
||||
|
||||
// The /file-viewer page embeds a same-origin PDF in a frame. The framework
|
||||
// defaults (`frame-ancestors 'none'` plus `X-Frame-Options: DENY`) stop an
|
||||
// app framing even its own assets, so same-origin framing is allowed here.
|
||||
// Cross-origin framing stays blocked.
|
||||
frameOptions: "SAMEORIGIN",
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
"frame-ancestors": ["'self'"],
|
||||
},
|
||||
},
|
||||
cors: {
|
||||
enabled: true,
|
||||
origin: ["http://localhost:5173", "http://localhost:3000"],
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
layout Gallery {
|
||||
view {
|
||||
<div class="gallery-shell">
|
||||
<header class="gallery-header"><a href="/preview" class="showcase-brand">WRNexus UI</a><nav aria-label="Main navigation"><a href="/docs">Components</a><a href="/preview">Examples</a><a href="/block-library">Blocks</a><a href="/templates">Templates</a></nav></header>
|
||||
<main class="gallery-main"><slot /></main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ layout Showcase {
|
||||
<label class="docs-search"><span class="icon-[lucide--search] size-4" aria-hidden="true"></span><input type="search" placeholder="Search documentation..." data-docs-search /><kbd>⌘ K</kbd></label>
|
||||
</div>
|
||||
<div class="showcase-header-end">
|
||||
<nav aria-label="Primary navigation" class="showcase-nav"><a href="/docs">Docs</a><a href="/block-library">Blocks</a><a href="/templates">Templates</a></nav>
|
||||
<nav aria-label="Primary navigation" class="showcase-nav"><a href="/preview">Examples</a><a href="/docs">Docs</a><a href="/block-library">Blocks</a><a href="/templates">Templates</a></nav>
|
||||
<button type="button" class="showcase-theme" data-design-panel-toggle aria-label="Customize design"><span class="icon-[lucide--palette] size-5" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -26,24 +26,19 @@ layout Showcase {
|
||||
<aside class="docs-directory" data-docs-directory>
|
||||
<nav aria-label="Documentation directory">
|
||||
<section class="docs-directory-group docs-directory-guides"><strong>Getting Started</strong><div><a href="/docs">Introduction</a><a href="/installation">Installation</a><a href="/framework-guides">Framework guides</a><a href="/accessibility">Accessibility</a><a href="/resources">Resources</a></div></section>
|
||||
<section class="docs-directory-group docs-directory-guides"><strong>WRNexusJS v0.6</strong><div><a href="/v06-types">Types</a><a href="/v06-functions">Functions</a><a href="/v06-outputs">Outputs</a><a href="/v06-stores">Stores</a><a href="/v06-imports">Imports</a><a href="/v06-state">State</a><a href="/v06-runtime">Runtime</a><a href="/v06-migration">Migration</a></div></section>
|
||||
<section class="docs-directory-group docs-directory-guides"><strong>Customization</strong><div><a href="/dark-mode">Dark Mode</a><a href="/themes">Themes <small>New</small></a><a href="/colors">Colors <small>New</small></a><a href="/fonts">Fonts</a><a href="/sizing">Sizing</a></div></section>
|
||||
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/advanced-forms">Advanced Forms</a>
|
||||
<div><a data-docs-component-link href="/components/advanced-select">Advanced Select</a><a data-docs-component-link href="/components/combo-box">Combo Box</a><a data-docs-component-link href="/components/input-number">Input Number</a><a data-docs-component-link href="/components/pin-input">Pin Input</a><a data-docs-component-link href="/components/strong-password">Strong Password</a><a data-docs-component-link href="/components/toggle-count">Toggle Count</a><a data-docs-component-link href="/components/toggle-password">Toggle Password</a></div>
|
||||
</section>
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/base">Base</a>
|
||||
<div><a data-docs-component-link href="/components/accordion">Accordion</a><a data-docs-component-link href="/components/alert">Alert</a><a data-docs-component-link href="/components/avatar">Avatar</a><a data-docs-component-link href="/components/avatar-group">Avatar Group</a><a data-docs-component-link href="/components/badge">Badge</a><a data-docs-component-link href="/components/blockquote">Blockquote</a><a data-docs-component-link href="/components/button">Button</a><a data-docs-component-link href="/components/button-group">Button Group</a><a data-docs-component-link href="/components/card">Card</a><a data-docs-component-link href="/components/carousel">Carousel</a><a data-docs-component-link href="/components/chat-bubble">Chat Bubble</a><a data-docs-component-link href="/components/collapse">Collapse</a><a data-docs-component-link href="/components/date-picker">Date Picker</a><a data-docs-component-link href="/components/device-frame">Device Frame</a><a data-docs-component-link href="/components/file-upload-progress">File Upload Progress</a><a data-docs-component-link href="/components/legend-indicator">Legend Indicator</a><a data-docs-component-link href="/components/list">List</a><a data-docs-component-link href="/components/list-group">List Group</a><a data-docs-component-link href="/components/marquee">Marquee</a><a data-docs-component-link href="/components/progress">Progress</a><a data-docs-component-link href="/components/rating">Rating</a><a data-docs-component-link href="/components/skeleton">Skeleton</a><a data-docs-component-link href="/components/spinner">Spinner</a><a data-docs-component-link href="/components/styled-icon">Styled Icon</a><a data-docs-component-link href="/components/timeline">Timeline</a><a data-docs-component-link href="/components/tree-view">Tree View</a></div>
|
||||
</section>
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/core">Core</a>
|
||||
<div><a data-docs-component-link href="/components/auth-form">Auth Form</a><a data-docs-component-link href="/components/auth-split-layout">Auth Split Layout</a><a data-docs-component-link href="/components/portal-dashboard">Portal Dashboard</a><a data-docs-component-link href="/components/preference-switcher">Preference Switcher</a><a data-docs-component-link href="/components/toaster">Toaster</a></div>
|
||||
<div><a data-docs-component-link href="/components/preference-switcher">Preference Switcher</a><a data-docs-component-link href="/components/toaster">Toaster</a></div>
|
||||
</section>
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/data">Data</a>
|
||||
<div><a data-docs-component-link href="/components/metric-card">Metric Card</a><a data-docs-component-link href="/components/metric-grid">Metric Grid</a><a data-docs-component-link href="/components/stats-bar">Stats Bar</a></div>
|
||||
<div><a data-docs-component-link href="/components/metric-card">Metric Card</a></div>
|
||||
</section>
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/forms">Forms</a>
|
||||
@@ -55,11 +50,11 @@ layout Showcase {
|
||||
</section>
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/layout">Layout</a>
|
||||
<div><a data-docs-component-link href="/components/columns">Columns</a><a data-docs-component-link href="/components/container">Container</a><a data-docs-component-link href="/components/custom-scrollbar">Custom Scrollbar</a><a data-docs-component-link href="/components/divider">Divider</a><a data-docs-component-link href="/components/feature-grid">Feature Grid</a><a data-docs-component-link href="/components/footer">Footer</a><a data-docs-component-link href="/components/grid">Grid</a><a data-docs-component-link href="/components/image">Image</a><a data-docs-component-link href="/components/kbd">Kbd</a><a data-docs-component-link href="/components/layout-splitter">Layout Splitter</a><a data-docs-component-link href="/components/link">Link</a><a data-docs-component-link href="/components/public-page-shell">Public Page Shell</a><a data-docs-component-link href="/components/section">Section</a><a data-docs-component-link href="/components/section-header">Section Header</a><a data-docs-component-link href="/components/typography">Typography</a></div>
|
||||
<div><a data-docs-component-link href="/components/columns">Columns</a><a data-docs-component-link href="/components/container">Container</a><a data-docs-component-link href="/components/custom-scrollbar">Custom Scrollbar</a><a data-docs-component-link href="/components/divider">Divider</a><a data-docs-component-link href="/components/grid">Grid</a><a data-docs-component-link href="/components/image">Image</a><a data-docs-component-link href="/components/kbd">Kbd</a><a data-docs-component-link href="/components/layout-splitter">Layout Splitter</a><a data-docs-component-link href="/components/link">Link</a><a data-docs-component-link href="/components/section">Section</a><a data-docs-component-link href="/components/typography">Typography</a></div>
|
||||
</section>
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/marketing">Marketing</a>
|
||||
<div><a data-docs-component-link href="/components/announcement-bar">Announcement Bar</a><a data-docs-component-link href="/components/ctasection">CTASection</a><a data-docs-component-link href="/components/feature-card">Feature Card</a><a data-docs-component-link href="/components/feature-icon-card">Feature Icon Card</a><a data-docs-component-link href="/components/hero">Hero</a><a data-docs-component-link href="/components/hero-actions">Hero Actions</a><a data-docs-component-link href="/components/marketing-section-header">Marketing Section Header</a><a data-docs-component-link href="/components/page-header">Page Header</a><a data-docs-component-link href="/components/split-hero">Split Hero</a><a data-docs-component-link href="/components/text-link">Text Link</a></div>
|
||||
<div><a data-docs-component-link href="/components/text-link">Text Link</a></div>
|
||||
</section>
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/navigation">Navigation</a>
|
||||
@@ -72,9 +67,13 @@ layout Showcase {
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/tables">Tables</a>
|
||||
<div><a data-docs-component-link href="/components/data-table">Data Table</a></div>
|
||||
</section>
|
||||
<section class="docs-directory-group">
|
||||
<a class="docs-directory-heading" href="/advanced-forms">Advanced Forms</a>
|
||||
<div><a data-docs-component-link href="/components/advanced-select">Advanced Select</a><a data-docs-component-link href="/components/combo-box">Combo Box</a><a data-docs-component-link href="/components/input-number">Input Number</a><a data-docs-component-link href="/components/pin-input">Pin Input</a><a data-docs-component-link href="/components/strong-password">Strong Password</a><a data-docs-component-link href="/components/toggle-count">Toggle Count</a><a data-docs-component-link href="/components/toggle-password">Toggle Password</a></div>
|
||||
</section>
|
||||
</nav>
|
||||
<p class="docs-directory-empty" data-docs-empty>No components found.</p>
|
||||
<p class="docs-directory-empty" data-docs-empty hidden>No components found.</p>
|
||||
</aside>
|
||||
<main class="showcase-main"><slot /></main>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ page AccessibilityGuide {
|
||||
<nav class="docs-breadcrumbs" aria-label="Breadcrumb"><a href="/docs">Docs</a><span aria-hidden="true">/</span><span>Accessibility</span></nav>
|
||||
<header class="docs-intro"><span class="showcase-eyebrow">Getting Started</span><h1>Accessibility</h1><p>Keyboard interaction, visible focus states, labels, and reduced-motion support ship by default.</p></header>
|
||||
<section class="guide-section"><h2>Overview</h2><p>Keyboard interaction, visible focus states, labels, and reduced-motion support ship by default.</p></section><section class="guide-section"><h2>Configuration</h2><p>Use the design switcher in the header to test this setting live.</p></section><section class="guide-section"><h2>Component behavior</h2><p>The setting is inherited by components, blocks, templates, and playground previews.</p></section>
|
||||
<section class="guide-section"><h2>Label controls and explain errors</h2><p>Give every control a visible label. Use helperText for instructions and error for actionable feedback. Test Tab, Shift+Tab, Enter and Escape; focus must remain visible and follow the reading order.</p><pre><code><Input label="Email address" type="email" name="email" required="true" helperText="Use your work email." error="Enter a valid email address." />
|
||||
<Button label="Save changes" disabled="true" /></code></pre><h3>Verify the result</h3><p>Open the matching component page, change its options, and try the keyboard controls. Copy the demonstrated configuration into your page, then replace sample content with your data.</p></section>
|
||||
|
||||
<nav class="guide-next"><a href="/components/button">Explore components <span aria-hidden="true">→</span></a><a href="/block-library">Browse blocks <span aria-hidden="true">→</span></a></nav>
|
||||
</article>
|
||||
|
||||
@@ -60,13 +60,13 @@ page AdvancedFormsShowcase {
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Advanced Forms</span><h2>Strong Password</h2><p>Theme-aware, responsive strong password component.</p></div>
|
||||
<div class="catalog-card-footer"><span>27 props · 0 slots</span><a href="/components/strong-password">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>29 props · 0 slots</span><a href="/components/strong-password">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><ToggleCount size="default" variant="segmented" class="showcase-instance showcase-instance--1" value="sample-1" firstLabel="Toggle Count" secondLabel="Toggle Count" ariaLabel="Toggle Count 1 example" items='[{"id":"toggle-count-0-1","key":"toggle-count-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#toggle-count-demo","actionHref":"#toggle-count-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"toggle-count-0-2","key":"toggle-count-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#toggle-count-demo","actionHref":"#toggle-count-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' align="start" fullWidth="false" disabled="false" animate="true" animationDuration="450" animationSteps="18" /></div>
|
||||
<div class="catalog-card-stage"><ToggleCount size="default" variant="segmented" class="showcase-instance showcase-instance--1" value="monthly" firstValue="monthly" firstLabel="Monthly" secondValue="annual" secondLabel="Yearly" ariaLabel="Toggle Count 1 example" items='[{"title":"Starter","label":"Starter","description":"For individuals getting started.","monthly":12,"annual":120},{"title":"Team","label":"Team","description":"For teams working together.","monthly":29,"annual":290}]' firstValueKey="monthly" secondValueKey="annual" align="start" fullWidth="false" disabled="false" animate="true" animationDuration="450" animationSteps="18" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Advanced Forms</span><h2>Toggle Count</h2><p>Theme-aware, responsive toggle count component.</p></div>
|
||||
@@ -80,7 +80,7 @@ page AdvancedFormsShowcase {
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Advanced Forms</span><h2>Toggle Password</h2><p>Accessible password field with optional show and hide controls.</p></div>
|
||||
<div class="catalog-card-footer"><span>24 props · 0 slots</span><a href="/components/toggle-password">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>27 props · 0 slots</span><a href="/components/toggle-password">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article></div>
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ page BaseShowcase {
|
||||
<span class="showcase-eyebrow">Component category</span>
|
||||
<h1 class="showcase-title">Base</h1>
|
||||
<p class="showcase-description">26 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p>
|
||||
<div class="category-meta"><span>26 components</span><span>Multiple use cases</span><span>83 live configurations</span></div>
|
||||
<div class="category-meta"><span>26 components</span><span>Multiple use cases</span><span>85 live configurations</span></div>
|
||||
</header>
|
||||
<div class="catalog-grid">
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><Accordion size="default" variant="default" class="showcase-instance showcase-instance--1" items='[{"id":"accordion-0-1","key":"accordion-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#accordion-demo","actionHref":"#accordion-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"accordion-0-2","key":"accordion-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#accordion-demo","actionHref":"#accordion-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' defaultOpen='[{"id":"accordion-0-1","key":"accordion-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#accordion-demo","actionHref":"#accordion-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"accordion-0-2","key":"accordion-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#accordion-demo","actionHref":"#accordion-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' multiple="false" alwaysOpen="false" disabled="false" showIndicator="true" bordered="false" separated="false" flush="false" contentItalic="false" /></div>
|
||||
<div class="catalog-card-stage"><Accordion size="default" variant="default" class="showcase-instance showcase-instance--1" items='[{"value":"workspace","label":"How do I create a workspace?","content":"Choose Create workspace, add a name, and invite your team. You can update the details any time in Settings."},{"value":"billing","label":"Can I change my plan later?","content":"Yes. Open Billing to compare plans and choose the one that suits your team."},{"value":"security","label":"How is my account protected?","content":"Enable two-step verification and add a recovery method to keep your account secure."}]' defaultOpen='["workspace"]' multiple="false" alwaysOpen="false" disabled="false" indicator="chevron" indicatorPosition="end" showIndicator="true" bordered="false" separated="false" flush="false" contentItalic="false" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Accordion</h2><p>Theme-aware, responsive accordion component.</p></div>
|
||||
@@ -26,7 +26,7 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><Alert size="default" variant="soft" class="showcase-instance showcase-instance--1" title="Alert example" description="A polished default alert for a modern application." items='[{"id":"alert-0-1","key":"alert-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#alert-demo","actionHref":"#alert-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"alert-0-2","key":"alert-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#alert-demo","actionHref":"#alert-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' actions='[{"id":"alert-0-1","key":"alert-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#alert-demo","actionHref":"#alert-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"alert-0-2","key":"alert-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#alert-demo","actionHref":"#alert-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' showIcon="true" icon="" dismissible="true" dismissLabel="Alert" linkLabel="Alert" linkHref="#alert-demo" actionLabel="Alert" actionHref="#alert-demo" compact="false" /></div>
|
||||
<div class="catalog-card-stage"><Alert size="default" color="success" variant="soft" class="" radius="md" shadow="none" title="Changes saved" description="Your workspace settings are up to date." items='[]' actions='[]' showIcon="true" icon="icon-[lucide--circle-check]" dismissible="true" dismissLabel="Dismiss alert" role="alert" live="polite" linkLabel="" linkHref="" actionLabel="" actionHref="" compact="false" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Alert</h2><p>Theme-aware, responsive alert component.</p></div>
|
||||
@@ -36,7 +36,7 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><Avatar size="md" variant="solid" statusLabel="Avatar" badgeLabel="Avatar" description="A polished default avatar for a modern application." class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><Avatar src="" alt="Aisha Khan" initials="AK" size="md" color="primary" variant="solid" shape="circle" status="" statusLabel="" statusPosition="bottom" badge="" badgeIcon="" badgeLabel="" tooltip="Aisha Khan · Product designer" name="Aisha Khan" description="" loading="lazy" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Avatar</h2><p>Theme-aware, responsive avatar component.</p></div>
|
||||
@@ -46,7 +46,7 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><AvatarGroup items='[{"id":"avatar-group-0-1","key":"avatar-group-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#avatar-group-demo","actionHref":"#avatar-group-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"avatar-group-0-2","key":"avatar-group-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#avatar-group-demo","actionHref":"#avatar-group-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' size="md" variant="solid" maxVisible="4" columns="3" showTooltips="true" overflowLabel="Avatar Group" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><AvatarGroup items='[{"name":"Aisha Khan","initials":"AK","tooltip":"Aisha Khan · Product designer"},{"name":"Daniel Lee","initials":"DL","tooltip":"Daniel Lee · Engineer"},{"name":"Maya Patel","initials":"MP","tooltip":"Maya Patel · Product manager"},{"name":"Oliver Reed","initials":"OR","tooltip":"Oliver Reed · Support"},{"name":"Sofia Chen","initials":"SC","tooltip":"Sofia Chen · Operations"}]' size="md" color="primary" variant="solid" shape="circle" layout="stack" maxVisible="3" columns="3" borderColor="" showTooltips="true" overflowLabel="Show remaining members" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Avatar Group</h2><p>Theme-aware, responsive avatar group component.</p></div>
|
||||
@@ -56,11 +56,11 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><Badge label="Badge" size="md" variant="solid" class="showcase-instance showcase-instance--1" icon="" dot="false" dotOnly="false" dotLabel="Badge" animated="false" dismissible="true" dismissLabel="Badge" truncate="false" anchorLabel="Badge" /></div>
|
||||
<div class="catalog-card-stage"><Badge label="Active" humanize="true" size="sm" color="success" variant="soft" class="showcase-instance showcase-instance--1" icon="" dot="false" dotOnly="false" dotLabel="Status" animated="false" dismissible="false" dismissLabel="Remove badge" truncate="false" anchorLabel="" anchorIcon="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Badge</h2><p>Theme-aware, responsive badge component.</p></div>
|
||||
<div class="catalog-card-footer"><span>22 props · 0 slots</span><a href="/components/badge">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>23 props · 0 slots</span><a href="/components/badge">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
@@ -70,7 +70,7 @@ page BaseShowcase {
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Blockquote</h2><p>Theme-aware, responsive blockquote component.</p></div>
|
||||
<div class="catalog-card-footer"><span>13 props · 1 slots</span><a href="/components/blockquote">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>14 props · 1 slots</span><a href="/components/blockquote">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
@@ -80,13 +80,13 @@ page BaseShowcase {
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Button</h2><p>Theme-aware, responsive button component.</p></div>
|
||||
<div class="catalog-card-footer"><span>25 props · 1 slots</span><a href="/components/button">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>26 props · 1 slots</span><a href="/components/button">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><ButtonGroup items='[{"id":"button-group-0-1","key":"button-group-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#button-group-demo","actionHref":"#button-group-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"button-group-0-2","key":"button-group-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#button-group-demo","actionHref":"#button-group-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' value="sample-1" size="md" variant="default" responsive="false" attached="true" selectable="false" toolbar="false" disabled="false" ariaLabel="Button Group 1 example" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><ButtonGroup items='[{"label":"Day","value":"day"},{"label":"Week","value":"week"},{"label":"Month","value":"month"}]' value="week" size="md" color="primary" variant="default" orientation="horizontal" responsive="false" attached="true" selectable="true" toolbar="false" disabled="false" ariaLabel="Button group" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Button Group</h2><p>Theme-aware, responsive button group component.</p></div>
|
||||
@@ -96,27 +96,27 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><Card title="Citizen service" subtitle="Card example" description="Access a trusted online service." actionLabel="Card" actionHref="#card-demo" headerActions='[{"id":"card-0-1","key":"card-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#card-demo","actionHref":"#card-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"card-0-2","key":"card-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#card-demo","actionHref":"#card-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' navigation='[{"id":"card-0-1","key":"card-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#card-demo","actionHref":"#card-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"card-0-2","key":"card-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#card-demo","actionHref":"#card-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' mobileNavigation="false" alertTitle="Card example" alertDescription="A polished default card for a modern application." empty="false" emptyTitle="Card example" items='[{"id":"card-0-1","key":"card-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#card-demo","actionHref":"#card-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"card-0-2","key":"card-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable messagLine truncated
|
||||
<div class="catalog-card-stage"><Card title="Citizen service" icon="" titleKey="" subtitle="" subtitleKey="" description="Access a trusted online service." descriptionKey="" showHeader="true" showFooter="true" header="" footer="" imageSrc="" imageAlt="" imagePosition="top" actionLabel="" actionLabelKey="" actionHref="" headerActions='[]' navigation='[]' activeNav="" mobileNavigation="false" alertTitle="" alertDescription="" empty="false" emptyTitle="No data to show" emptyIcon="icon-[lucide--inbox]" items='[]' size="md" color="primary" variant="default" layout="vertical" align="left" hover="none" radius="default" shadow="default" equalHeight="false" scrollable="false" maxHeight="18rem" dismissible="false" ariaLabel="" class=""><p class="showcase-card-copy">Supporting content belongs inside the default slot.</p></Card></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Card</h2><p>Group related content in a responsive themed surface with title, description, content, and supporting slots.</p></div>
|
||||
<div class="catalog-card-footer"><span>31 props · 1 slots</span><a href="/components/card">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>41 props · 3 slots</span><a href="/components/card">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><Carousel size="default" title="Carousel example" description="A polished default carousel for a modern application." items='[{"id":"carousel-0-1","key":"carousel-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#carousel-demo","actionHref":"#carousel-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"carousel-0-2","key":"carousel-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#carousel-demo","actionHref":"#carousel-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' activeIndex="0" slidesPerView="1" showPagination="true" isAutoPlay="false" autoplayInterval="4000" isInfiniteLoop="false" isRTL="false" isCentered="false" isDraggable="false" isAutoHeight="false" isSnap="false" showCounter="true" ariaLabel="Carousel 1 example" variant="default" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><Carousel size="default" color="primary" title="" description="" items='[{"title":"A space to create","description":"Bring your next idea to life.","imageSrc":"/showcase-images/green-hero.svg"},{"title":"Made for teamwork","description":"Keep everyone moving together.","imageSrc":"/showcase-images/blue-mountain.svg"},{"title":"Room to grow","description":"Build something that matters.","imageSrc":"/showcase-images/violet-circle.svg"}]' activeIndex="0" slidesPerView="1" gap="0.75rem" showPagination="true" isAutoPlay="false" autoplayInterval="4000" isInfiniteLoop="false" isRTL="false" isCentered="false" isDraggable="false" isAutoHeight="false" isSnap="false" showCounter="true" thumbnails="none" ariaLabel="Product highlights" framed="false" variant="default" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Carousel</h2><p>Theme-aware, responsive carousel component.</p></div>
|
||||
<div class="catalog-card-footer"><span>22 props · 1 slots</span><a href="/components/carousel">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>23 props · 1 slots</span><a href="/components/carousel">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><ChatBubble size="default" title="Chat Bubble example" description="A polished default chat bubble for a modern application." items='[{"id":"chat-bubble-0-1","key":"chat-bubble-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#chat-bubble-demo","actionHref":"#chat-bubble-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"chat-bubble-0-2","key":"chat-bubble-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#chat-bubble-demo","actionHref":"#chat-bubble-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' oneSided="false" showAvatars="true" showMetadata="true" ariaLabel="Chat Bubble 1 example" variant="default" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><ChatBubble size="default" color="primary" title="" description="" items='[{"text":"Hi Maya, is the new dashboard ready to review?","direction":"incoming","timestamp":"10:24"},{"text":"Yes! I have shared the latest version with you.","direction":"outgoing","timestamp":"10:25","receipt":"read"},{"text":"Thanks, I will take a look this afternoon.","direction":"incoming","timestamp":"10:26"},{"text":"Great. The release notes are attached too.","direction":"outgoing","timestamp":"10:27","receipt":"delivered"},{"text":"One more screenshot coming up.","direction":"outgoing","timestamp":"10:28","receipt":"sent"},{"text":"Uploading the final export now.","direction":"outgoing","timestamp":"10:29","receipt":"sending"},{"text":"Here is the invoice PDF.","direction":"outgoing","timestamp":"10:30","receipt":"failed","status":"Not delivered","actionLabel":"Retry"}]' oneSided="false" showAvatars="false" showMetadata="true" ariaLabel="Conversation" variant="default" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Chat Bubble</h2><p>Theme-aware, responsive chat bubble component.</p></div>
|
||||
@@ -136,7 +136,7 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><DatePicker size="default" label="Date Picker" value="sample-1" placeholder="Enter a sample value" firstDayOfWeek="0" variant="normal" inline="false" readonly="false" disabled="false" required="false" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><DatePicker size="default" label="Start date" name="start-date" value="2026-09-20" placeholder="Enter a sample value" type="date" firstDayOfWeek="0" months='[{"label":"Jan","value":"01"},{"label":"Feb","value":"02"},{"label":"Mar","value":"03"},{"label":"Apr","value":"04"},{"label":"May","value":"05"},{"label":"Jun","value":"06"},{"label":"Jul","value":"07"},{"label":"Aug","value":"08"},{"label":"Sep","value":"09"},{"label":"Oct","value":"10"},{"label":"Nov","value":"11"},{"label":"Dec","value":"12"}]' days='[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31]' years='[2024,2025,2026,2027,2028,2029,2030]' variant="normal" inline="false" readonly="false" disabled="false" required="false" class="showcase-instance showcase-instance--1" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Date Picker</h2><p>Theme-aware, responsive date picker component.</p></div>
|
||||
@@ -156,17 +156,17 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><FileUploadProgress size="default" label="File Upload Progress" value="36" max="100" showValue="true" cancelLabel="File Upload Progress" retryLabel="File Upload Progress" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><FileUploadProgress size="default" color="primary" label="Upload progress" value="50" max="100" showValue="true" fileName="brand-guidelines.pdf" fileSize="4.8 MB" uploadedSize="2.4 MB" status="uploading" cancelLabel="Cancel upload" retryLabel="Retry upload" showCompleteAction="false" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>File Upload Progress</h2><p>Theme-aware, responsive file upload progress component.</p></div>
|
||||
<div class="catalog-card-footer"><span>13 props · 0 slots</span><a href="/components/file-upload-progress">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>14 props · 0 slots</span><a href="/components/file-upload-progress">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><LegendIndicator size="default" title="Legend Indicator example" description="A polished default legend indicator for a modern application." items='[{"id":"legend-indicator-0-1","key":"legend-indicator-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#legend-indicator-demo","actionHref":"#legend-indicator-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"legend-indicator-0-2","key":"legend-indicator-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#legend-indicator-demo","actionHref":"#legend-indicator-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' variant="default" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><LegendIndicator size="default" title="Traffic sources" description="Select a source to toggle its visibility." items='[{"label":"Direct","color":"#8b5cf6","value":"42%"},{"label":"Search","color":"#14b8a6","value":"35%"},{"label":"Referral","color":"#f59e0b","value":"23%"}]' variant="default" class="showcase-instance showcase-instance--1" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Legend Indicator</h2><p>Theme-aware, responsive legend indicator component.</p></div>
|
||||
@@ -186,11 +186,11 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><ListGroup size="default" title="List Group example" description="A polished default list group for a modern application." items='[{"id":"list-group-0-1","key":"list-group-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#list-group-demo","actionHref":"#list-group-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"list-group-0-2","key":"list-group-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#list-group-demo","actionHref":"#list-group-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' variant="default" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><ListGroup size="default" color="primary" title="Workspace" description="" items='[{"value":"inbox","label":"Inbox","description":"Messages that need your attention","icon":"icon-[lucide--inbox]","badge":"8"},{"value":"projects","label":"Projects","description":"Your team\u0027s active work","icon":"icon-[lucide--folder]"},{"value":"archive","label":"Archive","description":"Completed conversations","icon":"icon-[lucide--archive]"}]' variant="default" value="inbox" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>List Group</h2><p>Theme-aware, responsive list group component.</p></div>
|
||||
<div class="catalog-card-footer"><span>7 props · 1 slots</span><a href="/components/list-group">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>8 props · 1 slots</span><a href="/components/list-group">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
@@ -216,11 +216,11 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><Rating size="default" title="Rating example" description="A polished default rating for a modern application." items='[{"id":"rating-0-1","key":"rating-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#rating-demo","actionHref":"#rating-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"rating-0-2","key":"rating-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#rating-demo","actionHref":"#rating-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' variant="default" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><Rating size="default" color="primary" title="Rate your experience" description="" items='[]' variant="default" value="3" max="5" step="1" readonly="false" disabled="false" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Rating</h2><p>Theme-aware, responsive rating component.</p></div>
|
||||
<div class="catalog-card-footer"><span>7 props · 1 slots</span><a href="/components/rating">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>12 props · 1 slots</span><a href="/components/rating">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
@@ -246,27 +246,27 @@ page BaseShowcase {
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><StyledIcon size="default" title="Styled Icon example" description="A polished default styled icon for a modern application." items='[{"id":"styled-icon-0-1","key":"styled-icon-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#styled-icon-demo","actionHref":"#styled-icon-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"styled-icon-0-2","key":"styled-icon-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#styled-icon-demo","actionHref":"#styled-icon-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' variant="default" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><StyledIcon size="default" color="primary" title="Styled Icon" description="" items='[]' variant="default" icon="icon-[lucide--sparkles]" label="Verified" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Styled Icon</h2><p>Theme-aware, responsive styled icon component.</p></div>
|
||||
<div class="catalog-card-footer"><span>7 props · 1 slots</span><a href="/components/styled-icon">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>9 props · 0 slots</span><a href="/components/styled-icon">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><Timeline size="default" title="Request progress" description="Latest updates for this service request." items='[{"title":"Request submitted","description":"Received successfully","time":"09:30","status":"complete"},{"title":"Verification","description":"Documents are being reviewed","time":"10:15","current":true},{"title":"Decision","description":"Pending verification","time":"—"}]' variant="default" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><Timeline size="default" title="Request progress" description="Latest updates for this service request." items='[{"title":"Request submitted","description":"Received successfully","time":"09:30","status":"complete"},{"title":"Verification","description":"Documents are being reviewed","time":"10:15","current":true},{"title":"Decision","description":"Pending verification","time":"—"}]' variant="default" numbered="false" class="showcase-instance showcase-instance--1" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Timeline</h2><p>Present responsive chronological activity, milestones, or workflow status with rich item metadata.</p></div>
|
||||
<div class="catalog-card-footer"><span>7 props · 1 slots</span><a href="/components/timeline">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>8 props · 1 slots</span><a href="/components/timeline">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card" data-interactive-preview="false">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><TreeView title="Tree View example" description="A polished default tree view for a modern application." items='[{"id":"tree-view-0-1","key":"tree-view-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#tree-view-demo","actionHref":"#tree-view-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"tree-view-0-2","key":"tree-view-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#tree-view-demo","actionHref":"#tree-view-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' defaultExpanded='[{"id":"tree-view-0-1","key":"tree-view-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#tree-view-demo","actionHref":"#tree-view-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"tree-view-0-2","key":"tree-view-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#tree-view-demo","actionHref":"#tree-view-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' size="default" class="showcase-instance showcase-instance--1" /></div>
|
||||
<div class="catalog-card-stage"><TreeView title="Project files" description="" items='[{"value":"src","label":"src","children":[{"value":"pages","label":"pages","children":[{"value":"home","label":"index.wrn"}]},{"value":"styles","label":"styles.css"}]},{"value":"readme","label":"README.md"}]' valueKey="value" labelKey="label" defaultExpanded='["src"]' selected="" size="default" color="primary" class="" /></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Base</span><h2>Tree View</h2><p>Theme-aware, responsive tree view component.</p></div>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
page BlockLibrary {
|
||||
layout = "showcase"
|
||||
seo { title = "UI blocks" description = "Complete sections composed from WRNexus UI components." }
|
||||
view {
|
||||
<div class="page-marketplace page-blocks">
|
||||
<nav class="market-tabs"><a href="#all">All blocks</a><a href="#marketing">Marketing</a><a href="#application">Application UI</a><a href="#ecommerce">Ecommerce</a><a href="#blog">Blog & Articles</a></nav>
|
||||
<header><span class="showcase-eyebrow">Composable sections</span><h1>Ready-to-use UI blocks</h1><p>Complete responsive sections composed exclusively from the components in this library.</p></header>
|
||||
<div class="market-grid"><article class="market-card"><div class="market-preview market-preview--1"><span class="icon-[lucide--layout-template] size-10"></span><div><i></i><i></i><i></i></div></div><div><small>Marketing</small><h2>Hero sections</h2><p>Hero, actions, product proof, and visual stage.</p><a href="/components/container">View components →</a></div></article><article class="market-card"><div class="market-preview market-preview--2"><span class="icon-[lucide--grid-2x2] size-10"></span><div><i></i><i></i><i></i></div></div><div><small>Marketing</small><h2>Feature grids</h2><p>Responsive benefits and product capability sections.</p><a href="/components/grid">View components →</a></div></article><article class="market-card"><div class="market-preview market-preview--3"><span class="icon-[lucide--badge-dollar-sign] size-10"></span><div><i></i><i></i><i></i></div></div><div><small>Marketing</small><h2>Pricing sections</h2><p>Tier cards, comparison grids, and CTAs.</p><a href="/components/container">View components →</a></div></article><article class="market-card"><div class="market-preview market-preview--4"><span class="icon-[lucide--panel-left] size-10"></span><div><i></i><i></i><i></i></div></div><div><small>Application UI</small><h2>Application shells</h2><p>Headers, sidebars, content, and command areas.</p><a href="/components/grid">View components →</a></div></article><article class="market-card"><div class="market-preview market-preview--1"><span class="icon-[lucide--chart-no-axes-combined] size-10"></span><div><i></i><i></i><i></i></div></div><div><small>Application UI</small><h2>Dashboard stats</h2><p>Metrics, trends, filters, and summaries.</p><a href="/components/container">View components →</a></div></article><article class="market-card"><div class="market-preview market-preview--2"><span class="icon-[lucide--shopping-cart] size-10"></span><div><i></i><i></i><i></i></div></div><div><small>Ecommerce</small><h2>Checkout panels</h2><p>Cart, address, payment, and order summary.</p><a href="/components/grid">View components →</a></div></article><article class="market-card"><div class="market-preview market-preview--3"><span class="icon-[lucide--newspaper] size-10"></span><div><i></i><i></i><i></i></div></div><div><small>Blog & Articles</small><h2>Article layouts</h2><p>Editorial headers, content, author, and sharing.</p><a href="/components/container">View components →</a></div></article><article class="market-card"><div class="market-preview market-preview--4"><span class="icon-[lucide--shield-check] size-10"></span><div><i></i><i></i><i></i></div></div><div><small>Application UI</small><h2>Authentication</h2><p>Sign in, registration, recovery, and verification.</p><a href="/components/grid">View components →</a></div></article></div>
|
||||
</div>
|
||||
}
|
||||
layout = "gallery"
|
||||
seo { title = "UI blocks" }
|
||||
view { <header class="docs-intro"><span class="showcase-eyebrow">Composed sections</span><h1>Ready-to-use blocks</h1><p>Real sections with live previews and complete source code.</p></header><div class="example-catalog"><a class="example-card" href="/blocks/announcement-bar"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Announcement Bar</h2><p>Publish a responsive notice with badge, icon, supporting copy, action, dismiss behavior, and width controls.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/ctasection"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>CTASection</h2><p>Close a page or major section with conversion-focused copy, actions, and optional supporting visual content.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/feature-card"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Feature Card</h2><p>Present one linked feature or service with media, icon, badge, description, and action.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/feature-grid"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Feature Grid</h2><p>Arrange feature cards, services, solutions, or benefits in a responsive equal-height grid.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/feature-icon-card"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Feature Icon Card</h2><p>Present a compact feature or benefit with a styled icon, title, description, badge, and optional link.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/footer"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Footer</h2><p>Render structured responsive footer navigation, pre and post content, copyright content, links, and public events.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/hero"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Hero</h2><p>Build a full-width responsive hero with constrained content, actions, trust signals, and a structured or custom visual panel.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/hero-actions"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Hero Actions</h2><p>Group hero and campaign actions with consistent alignment, orientation, sizing, and responsive mobile stacking.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/marketing-section-header"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Marketing Section Header</h2><p>Introduce marketing content with an eyebrow, title, description, and optional linked action.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/metric-grid"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Metric Grid</h2><p>Arrange operational metrics, KPIs, public statistics, or service indicators in a responsive equal-height grid.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/page-header"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Page Header</h2><p>Introduce an internal or public page with breadcrumbs, icon, title, description, and primary or secondary actions.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/section-header"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Section Header</h2><p>Introduce a section with an eyebrow, title, description, alignment, and responsive heading hierarchy.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/split-hero"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Split Hero</h2><p>Build a responsive two-column introduction balancing descriptive content with a visual, image, or structured data panel.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/stats-bar"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Stats Bar</h2><p>Present a compact responsive strip of key facts, counts, performance indicators, or trust signals.</p><strong>Open example →</strong></diLine truncated
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
page AnnouncementBarBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Announcement Bar block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Announcement Bar</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><AnnouncementBar badge="Public notice" badgeIcon="icon-[lucide--sparkles]" message="All composite components are now available" description="Review each component and its recommended use cases." icon="icon-[lucide--megaphone]" actionLabel="View components" actionHref="#components" actionIcon="icon-[lucide--arrow-down]" dismissible="true" dismissLabel="Announcement Bar" sticky="false" compact="false" flush="false" mobileCompact="false" mobileMessageLines="2" hideBadgeOnMobile="false" hideDescriptionOnMobile="false" flushMobile="false" size="default" width="default" variant="soft" class="showcase-instance showcase-instance--1" /></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page AnnouncementBarExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<AnnouncementBar badge="Public notice" badgeIcon="icon-[lucide--sparkles]" message="All composite components are now available" description="Review each component and its recommended use cases." icon="icon-[lucide--megaphone]" actionLabel="View components" actionHref="#components" actionIcon="icon-[lucide--arrow-down]" dismissible="true" dismissLabel="Announcement Bar" sticky="false" compact="false" flush="false" mobileCompact="false" mobileMessageLines="2" hideBadgeOnMobile="false" hideDescriptionOnMobile="false" flushMobile="false" size="default" width="default" variant="soft" class="showcase-instance showcase-instance--1" />
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
page CTASectionBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "CTASection block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>CTASection</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><CTASection eyebrow="Ready to continue" title="Build public pages with reusable components" description="Keep application pages focused on content and business data while the shared UI package owns presentation." icon="icon-[lucide--rocket]" align="split" size="default" variant="gradient" primaryLabel="Use these components" primaryHref="#components" primaryIcon="icon-[lucide--blocks]" secondaryLabel="Back to home" secondaryHref="/" secondaryIcon="icon-[lucide--house]" visualTitle="CTASection example" visualDescription="A polished default ctasection for a modern application." visualItems='[{"id":"ctasection-0-1","key":"ctasection-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#ctasection-demo","actionHref":"#ctasection-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"ctasection-0-2","key":"ctasection-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#ctasection-demo","actionHref":"#ctasection-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' maxWidth="full" fullBleed="false" class="showcase-instance showcase-instance--1"><div data-slot="visual">
|
||||
<div class="showcase-visual-panel">
|
||||
<span class="icon-[lucide--layout-template] size-12" aria-hidden="true"></span>
|
||||
<strong>Composable visual panel</strong>
|
||||
<p>Add a screenshot, product preview, media block, chart, form, or illustration.</p>
|
||||
</div></div></CTASection></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page CTASectionExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<CTASection eyebrow="Ready to continue" title="Build public pages with reusable components" description="Keep application pages focused on content and business data while the shared UI package owns presentation." icon="icon-[lucide--rocket]" align="split" size="default" variant="gradient" primaryLabel="Use these components" primaryHref="#components" primaryIcon="icon-[lucide--blocks]" secondaryLabel="Back to home" secondaryHref="/" secondaryIcon="icon-[lucide--house]" visualTitle="CTASection example" visualDescription="A polished default ctasection for a modern application." visualItems='[<span>{</span>"id":"ctasection-0-1","key":"ctasection-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#ctasection-demo","actionHref":"#ctasection-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[<span>{</span>"label":"Nested option A","value":"nested-a"<span>}</span>,<span>{</span>"label":"Nested option B","value":"nested-b"<span>}</span>],"links":[<span>{</span>"label":"Documentation","href":"#documentation"<span>}</span>,<span>{</span>"label":"API reference","href":"#api-reference"<span>}</span>],"values":["Included","Standard"]<span>}</span>,<span>{</span>"id":"ctasection-0-2","key":"ctasection-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#ctasection-demo","actionHref":"#ctasection-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[<span>{</span>"label":"Nested option A","value":"nested-a"<span>}</span>,<span>{</span>"label":"Nested option B","value":"nested-b"<span>}</span>],"links":[<span>{</span>"label":"Documentation","href":"#documentation"<span>}</span>,<span>{</span>"label":"API reference","href":"#api-reference"<span>}</span>],"values":["Included","Standard"]<span>}</span>]' maxWidth="full" fullBleed="false" class="showcase-instance showcase-instance--1"><div data-slot="visual">
|
||||
<div class="showcase-visual-panel">
|
||||
<span class="icon-[lucide--layout-template] size-12" aria-hidden="true"></span>
|
||||
<strong>Composable visual panel</strong>
|
||||
<p>Add a screenshot, product preview, media block, chart, form, or illustration.</p>
|
||||
</div></div></CTASection>
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
page FeatureCardBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Feature Card block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Feature Card</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><FeatureCard icon="icon-[lucide--shield-check]" iconStyle="soft" eyebrow="Citizen support" title="Citizen services" description="Report incidents, request certificates, and find safety information." href="#citizen-services" external="false" actionLabel="Explore services" showArrow="true" stretchedLink="true" badge="Public" size="default" color="primary" variant="default" hover="lift" align="start" disabled="false" class="showcase-instance showcase-instance--1" /></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page FeatureCardExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<FeatureCard icon="icon-[lucide--shield-check]" iconStyle="soft" eyebrow="Citizen support" title="Citizen services" description="Report incidents, request certificates, and find safety information." href="#citizen-services" external="false" actionLabel="Explore services" showArrow="true" stretchedLink="true" badge="Public" size="default" color="primary" variant="default" hover="lift" align="start" disabled="false" class="showcase-instance showcase-instance--1" />
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
page FeatureGridBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Feature Grid block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Feature Grid</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><FeatureGrid size="default" items='[{"icon":"icon-[lucide--shield-check]","eyebrow":"Citizen support","title":"Citizen services","description":"Report incidents, request certificates, and find safety information.","href":"#citizen-services","actionLabel":"Explore services","badge":"Public","color":"primary","variant":"default","hover":"lift"},{"icon":"icon-[lucide--map-pin]","eyebrow":"Directory","title":"Station directory","description":"Locate offices, jurisdictions, contacts, and public service hours.","href":"#station-directory","actionLabel":"Find a station","badge":"Directory","color":"info","variant":"soft","hover":"border"},{"icon":"icon-[lucide--radio-tower]","eyebrow":"Verified","title":"Public updates","description":"Publish official alerts, notices, advisories, and verified information.","href":"#public-updates","actionLabel":"View updates","badge":"Live","color":"success","variant":"gradient","hover":"glow"}]' columns="3" tabletColumns="2" mobileColumns="1" gap="lg" equalHeight="true" align="start" maxWidth="full" variant="default" label="Feature Grid" class="showcase-instance showcase-instance--1" /></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page FeatureGridExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<FeatureGrid size="default" items='[<span>{</span>"icon":"icon-[lucide--shield-check]","eyebrow":"Citizen support","title":"Citizen services","description":"Report incidents, request certificates, and find safety information.","href":"#citizen-services","actionLabel":"Explore services","badge":"Public","color":"primary","variant":"default","hover":"lift"<span>}</span>,<span>{</span>"icon":"icon-[lucide--map-pin]","eyebrow":"Directory","title":"Station directory","description":"Locate offices, jurisdictions, contacts, and public service hours.","href":"#station-directory","actionLabel":"Find a station","badge":"Directory","color":"info","variant":"soft","hover":"border"<span>}</span>,<span>{</span>"icon":"icon-[lucide--radio-tower]","eyebrow":"Verified","title":"Public updates","description":"Publish official alerts, notices, advisories, and verified information.","href":"#public-updates","actionLabel":"View updates","badge":"Live","color":"success","variant":"gradient","hover":"glow"<span>}</span>]' columns="3" tabletColumns="2" mobileColumns="1" gap="lg" equalHeight="true" align="start" maxWidth="full" variant="default" label="Feature Grid" class="showcase-instance showcase-instance--1" />
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
page FeatureIconCardBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Feature Icon Card block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Feature Icon Card</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><FeatureIconCard icon="icon-[lucide--shield-check]" title="Secure by design" description="Build with consistent validation, accessibility, and theme support." href="#security" actionLabel="Review security" badge="Core" size="default" variant="default" align="start" clickableSurface="false" descriptionTone="A polished default feature icon card for a modern application." class="showcase-instance showcase-instance--1" /></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page FeatureIconCardExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<FeatureIconCard icon="icon-[lucide--shield-check]" title="Secure by design" description="Build with consistent validation, accessibility, and theme support." href="#security" actionLabel="Review security" badge="Core" size="default" variant="default" align="start" clickableSurface="false" descriptionTone="A polished default feature icon card for a modern application." class="showcase-instance showcase-instance--1" />
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
page FooterBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Footer block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Footer</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><Footer size="default" label="Footer" items='[{"type":"header","label":"Product","description":"Build consistent interfaces with WRNexusJS.","items":[{"label":"Components","href":"/base"},{"label":"Themes","href":"/themes"},{"label":"Examples","href":"/blocks"}]},{"type":"header","label":"Developers","items":[{"label":"Documentation","href":"/docs"},{"label":"Installation","href":"/installation"},{"label":"Framework guides","href":"/framework-guides"}]},{"type":"header","label":"Resources","items":[{"label":"Accessibility","href":"/accessibility"},{"label":"Design tokens","href":"/colors"},{"label":"Typography","href":"/fonts"}]},{"type":"header","label":"Company","items":[{"label":"WorkRoot","href":"https://workroot.in","external":true},{"label":"WRNexusJS","href":"https://wrnexusjs.dev","external":true}]}]' columns="4" maxWidth="wide" mobileColumns="2" copyright="© 2026 WRNexusJS. All rights reserved." class="showcase-instance showcase-instance--1" /></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page FooterExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<Footer size="default" label="Footer" items='[<span>{</span>"type":"header","label":"Product","description":"Build consistent interfaces with WRNexusJS.","items":[<span>{</span>"label":"Components","href":"/base"<span>}</span>,<span>{</span>"label":"Themes","href":"/themes"<span>}</span>,<span>{</span>"label":"Examples","href":"/blocks"<span>}</span>]<span>}</span>,<span>{</span>"type":"header","label":"Developers","items":[<span>{</span>"label":"Documentation","href":"/docs"<span>}</span>,<span>{</span>"label":"Installation","href":"/installation"<span>}</span>,<span>{</span>"label":"Framework guides","href":"/framework-guides"<span>}</span>]<span>}</span>,<span>{</span>"type":"header","label":"Resources","items":[<span>{</span>"label":"Accessibility","href":"/accessibility"<span>}</span>,<span>{</span>"label":"Design tokens","href":"/colors"<span>}</span>,<span>{</span>"label":"Typography","href":"/fonts"<span>}</span>]<span>}</span>,<span>{</span>"type":"header","label":"Company","items":[<span>{</span>"label":"WorkRoot","href":"https://workroot.in","external":true<span>}</span>,<span>{</span>"label":"WRNexusJS","href":"https://wrnexusjs.dev","external":true<span>}</span>]<span>}</span>]' columns="4" maxWidth="wide" mobileColumns="2" copyright="© 2026 WRNexusJS. All rights reserved." class="showcase-instance showcase-instance--1" />
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
page HeroActionsBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Hero Actions block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Hero Actions</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><HeroActions actions='[{"label":"Browse components","href":"#components","icon":"icon-[lucide--blocks]","variant":"primary"},{"label":"View metrics","href":"#metrics","icon":"icon-[lucide--chart-no-axes-column-increasing]","variant":"outline"}]' align="left" orientation="horizontal" stackOnMobile="true" fullWidthMobile="true" size="default" class="showcase-instance showcase-instance--1" /></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page HeroActionsExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<HeroActions actions='[<span>{</span>"label":"Browse components","href":"#components","icon":"icon-[lucide--blocks]","variant":"primary"<span>}</span>,<span>{</span>"label":"View metrics","href":"#metrics","icon":"icon-[lucide--chart-no-axes-column-increasing]","variant":"outline"<span>}</span>]' align="left" orientation="horizontal" stackOnMobile="true" fullWidthMobile="true" size="default" class="showcase-instance showcase-instance--1" />
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
page HeroBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Hero block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Hero</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><Hero eyebrow="Reusable public UI" eyebrowIcon="icon-[lucide--sparkles]" icon="" title="Build polished pages" highlight="with consistent hero layouts" description="Create strong first impressions for public portals, product pages, and service overviews." align="start" size="default" variant="gradient" layout="split" visualPosition="right" visualStyle="card" showDecorations="true" fullBleed="true" visualTitle="Hero example" visualDescription="A polished default hero for a modern application." visualItems='[{"id":"hero-0-1","key":"hero-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#hero-demo","actionHref":"#hero-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"hero-0-2","key":"hero-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#hero-demo","actionHref":"#hero-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' primaryLabel="Explore components" primaryHref="#components" primaryIcon="icon-[lucide--arrow-right]" secondaryLabel="Read documentation" secondaryHref="#docs" secondaryIcon="icon-[lucide--book-open]" tertiaryLabel="Hero" tertiaryHref="#hero-demo" badges='[{"id":"hero-0-1","key":"hero-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#hero-demo","actionHref":"#hero-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"hero-0-2","key":"hero-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#hero-demo","actionHref":"#hero-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' trustItems='[{"id":"hero-0-1","key":"hero-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#hero-demo","actionHref":"#hero-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,Line truncated
|
||||
<div class="showcase-visual-panel">
|
||||
<span class="icon-[lucide--layout-template] size-12" aria-hidden="true"></span>
|
||||
<strong>Composable visual panel</strong>
|
||||
<p>Add a screenshot, product preview, media block, chart, form, or illustration.</p>
|
||||
</div></div></Hero></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page HeroExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<Hero eyebrow="Reusable public UI" eyebrowIcon="icon-[lucide--sparkles]" icon="" title="Build polished pages" highlight="with consistent hero layouts" description="Create strong first impressions for public portals, product pages, and service overviews." align="start" size="default" variant="gradient" layout="split" visualPosition="right" visualStyle="card" showDecorations="true" fullBleed="true" visualTitle="Hero example" visualDescription="A polished default hero for a modern application." visualItems='[<span>{</span>"id":"hero-0-1","key":"hero-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#hero-demo","actionHref":"#hero-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[<span>{</span>"label":"Nested option A","value":"nested-a"<span>}</span>,<span>{</span>"label":"Nested option B","value":"nested-b"<span>}</span>],"links":[<span>{</span>"label":"Documentation","href":"#documentation"<span>}</span>,<span>{</span>"label":"API reference","href":"#api-reference"<span>}</span>],"values":["Included","Standard"]<span>}</span>,<span>{</span>"id":"hero-0-2","key":"hero-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#hero-demo","actionHref":"#hero-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[<span>{</span>"label":"Nested option A","value":"nested-a"<span>}</span>,<span>{</span>"label":"Nested option B","value":"nested-b"<span>}</span>],"links":[<span>{</span>"label":"Documentation","href":"#documentation"<span>}</span>,<span>{</span>"label":"API reference","href":"#api-reference"<span>}</span>],"values":["Included","Standard"]<span>}</span>]' primaryLabel="Explore components" primaryHref="#components" primaryIcon="icon-[lucide--arrow-right]" secondaryLabel="Read documentation" secondaryHref="#docs" secondaryIcon="icon-[lucide--book-open]" tertiaryLabel="Hero" tertiaryHref="#hero-demo" badges='[<span>{</span>"id":"hero-0-1","key":"hero-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#hero-demo","actionHref":"#hero-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[<span>{</span>"label":"Nested option A","value":"nested-a"<span>}</span>,<span>{</span>"label":"Nested option B","value":"nested-b"<span>}</span>],"links":[<span>{</span>"label":"Documentation","href":"#documentation"<span>}</span>,<span>{</span>"label":"API reference","href":"#api-reference"<span>}</span>],"values":["Included","Standard"]<span>}</span>,<span>{</span>"id":"hero-0-2","key":"hero-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#hero-demo","actionHref":"#hero-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[<span>{</span>"label":"Nested option A","value":"nested-a"<span>}</span>,<span>{</span>"label":"Nested option B","value":"nested-b"<span>}</span>],"links":[<span>{</span>"label":"Documentation","href":"#documentation"<span>}</span>,<span>{</span>"label":"API reference","href":"#api-reference"<span>}</span>],"values":["Included","Standard"]<span>}Line truncated
|
||||
<div class="showcase-visual-panel">
|
||||
<span class="icon-[lucide--layout-template] size-12" aria-hidden="true"></span>
|
||||
<strong>Composable visual panel</strong>
|
||||
<p>Add a screenshot, product preview, media block, chart, form, or illustration.</p>
|
||||
</div></div></Hero>
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
page BlocksIndex {
|
||||
layout = "gallery"
|
||||
seo { title = "UI blocks" }
|
||||
view { <div class="example-catalog"><a class="example-card" href="/blocks/announcement-bar"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Announcement Bar</h2><p>Publish a responsive notice with badge, icon, supporting copy, action, dismiss behavior, and width controls.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/ctasection"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>CTASection</h2><p>Close a page or major section with conversion-focused copy, actions, and optional supporting visual content.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/feature-card"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Feature Card</h2><p>Present one linked feature or service with media, icon, badge, description, and action.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/feature-grid"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Feature Grid</h2><p>Arrange feature cards, services, solutions, or benefits in a responsive equal-height grid.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/feature-icon-card"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Feature Icon Card</h2><p>Present a compact feature or benefit with a styled icon, title, description, badge, and optional link.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/footer"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Footer</h2><p>Render structured responsive footer navigation, pre and post content, copyright content, links, and public events.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/hero"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Hero</h2><p>Build a full-width responsive hero with constrained content, actions, trust signals, and a structured or custom visual panel.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/hero-actions"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Hero Actions</h2><p>Group hero and campaign actions with consistent alignment, orientation, sizing, and responsive mobile stacking.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/marketing-section-header"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Marketing Section Header</h2><p>Introduce marketing content with an eyebrow, title, description, and optional linked action.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/metric-grid"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Metric Grid</h2><p>Arrange operational metrics, KPIs, public statistics, or service indicators in a responsive equal-height grid.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/page-header"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Page Header</h2><p>Introduce an internal or public page with breadcrumbs, icon, title, description, and primary or secondary actions.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/section-header"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Section Header</h2><p>Introduce a section with an eyebrow, title, description, alignment, and responsive heading hierarchy.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/split-hero"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Split Hero</h2><p>Build a responsive two-column introduction balancing descriptive content with a visual, image, or structured data panel.</p><strong>Open example →</strong></div></a><a class="example-card" href="/blocks/stats-bar"><div class="example-card__art"><span class="icon-[lucide--layout-template]" aria-hidden="true"></span></div><div><h2>Stats Bar</h2><p>Present a compact responsive strip of key facts, counts, performance indicators, or trust signals.</p><strong>Open example →</strong></div></a></div> }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
page MarketingSectionHeaderBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Marketing Section Header block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Marketing Section Header</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><MarketingSectionHeader eyebrow="Platform capabilities" title="Build complete public experiences" description="Use reusable sections, cards, metrics, heroes, and CTAs." align="split" size="default" actionLabel="Explore components" actionHref="#components" actionIcon="icon-[lucide--arrow-right]" actionExternal="false" class="showcase-instance showcase-instance--1" /></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page MarketingSectionHeaderExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<MarketingSectionHeader eyebrow="Platform capabilities" title="Build complete public experiences" description="Use reusable sections, cards, metrics, heroes, and CTAs." align="split" size="default" actionLabel="Explore components" actionHref="#components" actionIcon="icon-[lucide--arrow-right]" actionExternal="false" class="showcase-instance showcase-instance--1" />
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
page MetricGridBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Metric Grid block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Metric Grid</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><MetricGrid items='[{"label":"Public services","value":"24","description":"Citizen-facing services available online.","icon":"icon-[lucide--hand-heart]","trend":"+4","trendLabel":"this release","trendDirection":"up","color":"primary"},{"label":"Police stations","value":"126","description":"Searchable station and jurisdiction entries.","icon":"icon-[lucide--landmark]","trend":"Updated","trendLabel":"today","color":"info"},{"label":"Availability","value":"99.9","suffix":"%","description":"Target service availability.","icon":"icon-[lucide--activity]","trend":"Stable","trendLabel":"30 days","trendDirection":"positive","color":"success"}]' columns="3" tabletColumns="2" mobileColumns="1" gap="lg" equalHeight="true" dividers="false" size="default" variant="default" maxWidth="full" class="showcase-instance showcase-instance--1" /></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page MetricGridExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<MetricGrid items='[<span>{</span>"label":"Public services","value":"24","description":"Citizen-facing services available online.","icon":"icon-[lucide--hand-heart]","trend":"+4","trendLabel":"this release","trendDirection":"up","color":"primary"<span>}</span>,<span>{</span>"label":"Police stations","value":"126","description":"Searchable station and jurisdiction entries.","icon":"icon-[lucide--landmark]","trend":"Updated","trendLabel":"today","color":"info"<span>}</span>,<span>{</span>"label":"Availability","value":"99.9","suffix":"%","description":"Target service availability.","icon":"icon-[lucide--activity]","trend":"Stable","trendLabel":"30 days","trendDirection":"positive","color":"success"<span>}</span>]' columns="3" tabletColumns="2" mobileColumns="1" gap="lg" equalHeight="true" dividers="false" size="default" variant="default" maxWidth="full" class="showcase-instance showcase-instance--1" />
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
page PageHeaderBlock {
|
||||
layout = "gallery"
|
||||
|
||||
seo { title = "Page Header block" }
|
||||
view { <header class="docs-intro"><a href="/block-library">← All blocks</a><h1>Page Header</h1><p>A ready-to-use section. Copy the complete example below.</p></header><section class="block-live-preview"><PageHeader eyebrow="Component showcase" title="Page headers with clear hierarchy" description="Use PageHeader for services, directories, legal pages, guides, dashboards, and public information." icon="icon-[lucide--panel-top]" align="start" centered="false" compact="false" size="default" maxWidth="full" showBreadcrumbs="true" breadcrumbs='[{"label":"Home","href":"/","icon":"icon-[lucide--house]"},{"label":"Components","href":"/base"},{"label":"Page header","current":true}]' breadcrumbParentHref="#page-header-demo" primaryLabel="Primary action" primaryHref="#primary" primaryIcon="icon-[lucide--arrow-down]" secondaryLabel="Secondary action" secondaryHref="#secondary" secondaryIcon="icon-[lucide--house]" variant="default" borderBottom="true" class="showcase-instance showcase-instance--1" /></section><section class="example-code-panel"><header><h2>Copy this example</h2><button type="button" data-copy-target="#example-source">Copy code</button></header><pre><code id="example-source">page PageHeaderExample <span>{</span>
|
||||
view <span>{</span>
|
||||
<PageHeader eyebrow="Component showcase" title="Page headers with clear hierarchy" description="Use PageHeader for services, directories, legal pages, guides, dashboards, and public information." icon="icon-[lucide--panel-top]" align="start" centered="false" compact="false" size="default" maxWidth="full" showBreadcrumbs="true" breadcrumbs='[<span>{</span>"label":"Home","href":"/","icon":"icon-[lucide--house]"<span>}</span>,<span>{</span>"label":"Components","href":"/base"<span>}</span>,<span>{</span>"label":"Page header","current":true<span>}</span>]' breadcrumbParentHref="#page-header-demo" primaryLabel="Primary action" primaryHref="#primary" primaryIcon="icon-[lucide--arrow-down]" secondaryLabel="Secondary action" secondaryHref="#secondary" secondaryIcon="icon-[lucide--house]" variant="default" borderBottom="true" class="showcase-instance showcase-instance--1" />
|
||||
<span>}</span>
|
||||
<span>}</span></code></pre></section> }
|
||||
}
|
||||
Loaded 100 of 924 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user