Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@ The kitchen sink demo and documentation for our UI components.

## Native QA

Build the site, then run every declared outcome with the font-enabled chuzz host
Build an uncompressed QA bundle, then run every declared outcome with the font-enabled chuzz host
and ps-qa 0.7.2 or newer:

```sh
bun run build
bun run build:apps
ps-qa --app tests/ps-qa/ps-qa.ron qa-hosted \
--host ../chuzz/target/release/chuzz-headless --page dist \
--checks tests/ps-qa/checks
```

The UI 3.2 registry build passes all 600 native checks across 13 groups,
The UI 3.2.2 registry build passes all 669 native checks across 13 groups,
including Calendar selection, month navigation, and keyboard and pointer-driven
Slider and Color Picker outcomes. CI includes every group.
Slider and Color Picker outcomes. The release gate includes every group.
The Layouts route uses its own document marker, and decorative cards are
articles rather than controls that promise an action.

The production response policy is a separate release check because chuzz does
not emulate browser CSP enforcement. It rejects a policy that blocks UI's
dynamic Slider and theme-preview styles, and it rejects stale Google Fonts
sources:

```sh
bun run qa:production-policy
```

## Code Style

- Keep code clean and self-documenting through clear variable/function names
Expand Down
1 change: 0 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,5 @@
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>

<script src="/src/index.tsx" type="module"></script>
</body>
</html>
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit",
"build": "bun run typecheck && rsbuild build && bun cleanup.js",
"build:apps": "rsbuild build",
"qa:production-policy": "bun scripts/verify-production-policy.mjs",
"preview": "rsbuild preview",
"prepare": "husky",
"lint": "biome check .",
Expand All @@ -35,7 +36,7 @@
"@iconify-json/lucide": "^1.2.127",
"@iconify-json/mdi": "^1.2.3",
"@iconify/tailwind4": "^1.0.6",
"@pathscale/ui": "^3.2.1",
"@pathscale/ui": "^3.2.2",
"@rsbuild/core": "^1.3.20",
"@rsbuild/plugin-babel": "^1.0.5",
"@solidjs/router": "2.0.0-next.19",
Expand Down
4 changes: 4 additions & 0 deletions rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import CompressionPlugin from "compression-webpack-plugin";
import { pluginSolid2LayoutsApplication } from "rsbuild-plugin-solid-layouts";

export default defineConfig({
html: {
template: "./index.html",
title: "JS.Software - SolidJS Component Library",
},
plugins: [
pluginSolid2LayoutsApplication({ layouts: ["@pathscale/ui"] }),
/*
Expand Down
76 changes: 76 additions & 0 deletions scripts/verify-production-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const targetUrl = process.env.PRODUCTION_POLICY_URL || "https://js.software/theming";

const response = await fetch(targetUrl, {
redirect: "follow",
headers: { Accept: "text/html" },
});

if (!response.ok) {
throw new Error(`Production policy check failed for ${targetUrl}: HTTP ${response.status}`);
}

const policy = response.headers.get("content-security-policy");
const html = await response.text();
const stylesheetUrls = [...html.matchAll(/<link\b[^>]*\brel=["'][^"']*stylesheet[^"']*["'][^>]*>/gi)]
.map(([tag]) => tag.match(/\bhref=["']([^"']+)["']/i)?.[1])
.filter(Boolean)
.map((href) => new URL(href, response.url).href);
const stylesheetBodies = await Promise.all(
stylesheetUrls.map(async (url) => {
const stylesheet = await fetch(url, { redirect: "follow" });
if (!stylesheet.ok) {
throw new Error(`Production stylesheet check failed for ${url}: HTTP ${stylesheet.status}`);
}
return stylesheet.text();
}),
);
const productionSource = [html, ...stylesheetBodies].join("\n");

if (/fonts\.(googleapis|gstatic)\.com/i.test(productionSource)) {
throw new Error("Production HTML or CSS still loads Google Fonts.");
}

if (!policy) {
console.log(`Production policy accepts runtime theme styles and has no Google Fonts source: ${targetUrl} has no CSP header.`);
process.exit(0);
}

const directives = new Map(
policy
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const [name, ...sources] = entry.split(/\s+/);
return [name.toLowerCase(), sources];
}),
);

const allSources = [...directives.values()].flat();
const googleFontSources = allSources.filter((source) =>
/(^|\.)fonts\.(googleapis|gstatic)\.com$/i.test(
source.replace(/^https?:\/\//, ""),
),
);

if (googleFontSources.length > 0) {
throw new Error(
`Production CSP still allows Google Fonts: ${googleFontSources.join(", ")}`,
);
}

const styleAttributeSources =
directives.get("style-src-attr") ??
directives.get("style-src") ??
directives.get("default-src") ??
[];

if (!styleAttributeSources.includes("'unsafe-inline'")) {
throw new Error(
"Production CSP blocks the dynamic style attributes used by UI sliders and the theme preview.",
);
}

console.log(
`Production policy accepts runtime theme styles and has no Google Fonts source: ${targetUrl}`,
);
20 changes: 7 additions & 13 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,28 @@ import { createRouter, useLocation } from "@solidjs/router";
import { ParentComponent, createEffect } from "solid-js";
import { routes } from "./routes";

import { BaseLayout } from "./layouts/BaseLayout";
import { MarketingHeader } from "./components/layout/Header/MarketingHeader";
import { BaseLayout } from "./layouts/BaseLayout";

const Layout: ParentComponent = (props) => {
const location = useLocation();

createEffect(
() => location.pathname,
() => window.scrollTo(0, 0),
() => {
// Chrome 152 returns a Promise from scrollTo(). Solid 2 treats an
// effect return value as cleanup, so return nothing explicitly.
window.scrollTo(0, 0);
},
);

return (
<BaseLayout
header={MarketingHeader}
class="min-h-screen"
>
<BaseLayout header={MarketingHeader} class="min-h-screen">
{props.children}
</BaseLayout>
);
};

/*
* Routes are configuration, not JSX children.
*
* `@solidjs/router` 2.x replaced <Router>/<Route> with a factory: the tree is
* declared once as plain objects and `createRouter` returns the provider
* component. The old `root` prop becomes the outermost route's `component`.
*/
const Routes = createRouter({
routes: [
{
Expand Down
49 changes: 40 additions & 9 deletions src/components/ComponentsDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Menu, Join } from "@pathscale/ui/lab";
import { ROUTES } from "../config/routes";
import { ActionStatus, createActionStatus } from "./showcase/ActionStatus";

export default function ComponentsDemo() {
export default function ComponentsDemo(props: { glassEnabled?: boolean }) {
const [modalOpen, setModalOpen] = createSignal(false);
const [page, setPage] = createSignal(2);
const [price, setPrice] = createSignal(25);
Expand All @@ -20,20 +20,34 @@ export default function ComponentsDemo() {
const [themeSwitch, setThemeSwitch] = createSignal(false);
const [previewSearch, setPreviewSearch] = createSignal("");
const [recommendation, setRecommendation] = createSignal("yes");
const [referralSource, setReferralSource] = createSignal<string | null>(null);
const actionStatus = createActionStatus("Preview ready");

return (
<div class="text-base-content mx-auto grid gap-6 pb-20 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<div class="md:col-span-2 lg:col-span-3">
<div class="components-demo-grid text-base-content mx-auto grid gap-6 pb-20">
<div class="col-span-full">
<ActionStatus message={actionStatus.message()} />
</div>
<Flex direction="col" gap="md">
<Card class="bg-base-100">
<Card
id="theme-preview-glass-card"
material={props.glassEnabled === false ? "solid" : "glass"}
variant={props.glassEnabled === false ? "plain" : "soft"}
elevation="md"
>
<Card.Body>
<Flex justify="between" align="center">
<Flex align="center" gap="sm">
<Icon src="mdi--filter-variant" width={16} height={16} />
<span class="font-semibold">Filters</span>
<Chip
id="theme-preview-material-label"
aria-label={`Preview material: ${props.glassEnabled === false ? "Solid" : "Glass"}`}
size="sm"
variant="flat"
>
{props.glassEnabled === false ? "Solid" : "Glass"}
</Chip>
</Flex>
<Button id="preview-more-filters" variant="ghost" size="sm" onClick={actionStatus.handler("More filters requested")}>
more
Expand Down Expand Up @@ -740,11 +754,28 @@ export default function ComponentsDemo() {
<Flex direction="col" gap="sm">
<Form>
<span class="text-sm font-medium">How did you hear about us?</span>
<Select id="preview-referral-source" placeholder="Select an option">
<Select.Option value="search">Search Engine</Select.Option>
<Select.Option value="social">Social Media</Select.Option>
<Select.Option value="friend">Friend</Select.Option>
<Select.Option value="ad">Advertisement</Select.Option>
<Select
id="preview-referral-source"
placeholder="Select an option"
value={referralSource()}
onChange={(value) => {
const selected = typeof value === "string" ? value : null;
setReferralSource(selected);
if (selected) actionStatus.announce(`Referral source selected: ${selected}`);
}}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<Select.Listbox>
<Select.Option value="search">Search Engine</Select.Option>
<Select.Option value="social">Social Media</Select.Option>
<Select.Option value="friend">Friend</Select.Option>
<Select.Option value="ad">Advertisement</Select.Option>
</Select.Listbox>
</Select.Popover>
</Select>
</Form>
<Form>
Expand Down
22 changes: 17 additions & 5 deletions src/components/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ export default function Preview(props: PreviewProps) {
};

return (
<div class="text-base-content pt-6 transition-colors duration-500 bg-base-300">
<div class="flex items-center justify-between gap-4 px-8 ps-10">
<h2 class="font-title text-lg md:max-lg:hidden">{TAB_TITLES[selectedKey()]}</h2>
<div
id="theme-preview"
class="text-base-content pt-6 transition-colors duration-500 bg-base-300"
style={{
"background-image":
"linear-gradient(rgb(0 0 0 / var(--theme-glass-scrim-opacity, 0%)), rgb(0 0 0 / var(--theme-glass-scrim-opacity, 0%)))",
}}
>
<div class="flex flex-col items-start gap-3 px-3 sm:flex-row sm:items-center sm:justify-between sm:px-8 sm:ps-10">
<h2 class="font-title text-lg">{TAB_TITLES[selectedKey()]}</h2>
<Tabs
id="theme-preview-tabs"
variant="primary"
Expand All @@ -37,18 +44,23 @@ export default function Preview(props: PreviewProps) {
<Tabs.List>
<Tabs.Tab id="demo" aria-label="Components Demo">
<Icon src="mdi--apps" width={16} height={16} />
<span>Demo</span>
</Tabs.Tab>
<Tabs.Tab id="variants" aria-label="Component Variants">
<Icon src="mdi--format-list-bulleted" width={16} height={16} />
<span>Variants</span>
</Tabs.Tab>
<Tabs.Tab id="palette" aria-label="Color Palette">
<Icon src="mdi--palette" width={16} height={16} />
<span>Palette</span>
</Tabs.Tab>
</Tabs.List>
</Tabs>
</div>
<div class="px-8 py-6">
{selectedKey() === "demo" && <ComponentsDemo />}
<div class="px-3 py-4 sm:px-8 sm:py-6">
{selectedKey() === "demo" && (
<ComponentsDemo glassEnabled={props.currentTheme._glassEnabled !== "0"} />
)}
{selectedKey() === "variants" && <ComponentVariants />}
{selectedKey() === "palette" && <ColorPalette currentTheme={props.currentTheme} />}
</div>
Expand Down
Loading
Loading