altitude-design 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -7
- package/dist/adapters/codemirror.cjs +88 -0
- package/dist/adapters/codemirror.cjs.map +7 -0
- package/dist/adapters/codemirror.js +67 -0
- package/dist/adapters/codemirror.js.map +7 -0
- package/dist/adapters/react-flow.css +102 -0
- package/dist/adapters/tiptap.css +71 -0
- package/dist/altitude.css +3121 -244
- package/dist/altitude.iife.js +33 -5
- package/dist/altitude.no-fonts.css +3121 -244
- package/dist/chart-themes.cjs +195 -0
- package/dist/chart-themes.cjs.map +7 -0
- package/dist/chart-themes.js +165 -0
- package/dist/chart-themes.js.map +7 -0
- package/dist/cli/altitude.mjs +134 -0
- package/dist/guidance/README.md +32 -0
- package/dist/guidance/agent-setup.md +22 -0
- package/dist/guidance/app-skeleton.md +17 -0
- package/dist/guidance/evidence.json +95 -0
- package/dist/guidance/index.json +493 -0
- package/dist/guidance/llms.txt +20 -0
- package/dist/guidance/manifest.json +92 -0
- package/dist/guidance/pattern.schema.json +181 -0
- package/dist/guidance/patterns/agent-action.json +77 -0
- package/dist/guidance/patterns/agent-action.md +39 -0
- package/dist/guidance/patterns/assistant-conversation.json +77 -0
- package/dist/guidance/patterns/assistant-conversation.md +50 -0
- package/dist/guidance/patterns/async-collection.json +71 -0
- package/dist/guidance/patterns/async-collection.md +29 -0
- package/dist/guidance/patterns/audit-log.json +75 -0
- package/dist/guidance/patterns/audit-log.md +27 -0
- package/dist/guidance/patterns/auth.json +86 -0
- package/dist/guidance/patterns/auth.md +29 -0
- package/dist/guidance/patterns/billing.json +80 -0
- package/dist/guidance/patterns/billing.md +29 -0
- package/dist/guidance/patterns/consequential-action.json +76 -0
- package/dist/guidance/patterns/consequential-action.md +27 -0
- package/dist/guidance/patterns/developer-settings.json +79 -0
- package/dist/guidance/patterns/developer-settings.md +29 -0
- package/dist/guidance/patterns/edit-record.json +74 -0
- package/dist/guidance/patterns/edit-record.md +51 -0
- package/dist/guidance/patterns/import.json +77 -0
- package/dist/guidance/patterns/import.md +31 -0
- package/dist/guidance/patterns/members.json +77 -0
- package/dist/guidance/patterns/members.md +27 -0
- package/dist/guidance/patterns/onboarding.json +78 -0
- package/dist/guidance/patterns/onboarding.md +31 -0
- package/dist/guidance/patterns/review-submit.json +72 -0
- package/dist/guidance/patterns/review-submit.md +27 -0
- package/dist/guidance/patterns/screen-layout.json +79 -0
- package/dist/guidance/patterns/screen-layout.md +40 -0
- package/dist/guidance/patterns/settings.json +78 -0
- package/dist/guidance/patterns/settings.md +32 -0
- package/dist/guidance/patterns/sync.json +77 -0
- package/dist/guidance/patterns/sync.md +31 -0
- package/dist/guidance/principles.md +23 -0
- package/dist/guidance/references/build/checks/agent-action-pattern.mjs +57 -0
- package/dist/guidance/references/build/checks/agent-setup.mjs +78 -0
- package/dist/guidance/references/build/checks/assistant-conversation-pattern.mjs +49 -0
- package/dist/guidance/references/build/checks/audit-log-pattern.mjs +63 -0
- package/dist/guidance/references/build/checks/auth-pattern.mjs +67 -0
- package/dist/guidance/references/build/checks/billing-pattern.mjs +63 -0
- package/dist/guidance/references/build/checks/developer-settings-pattern.mjs +76 -0
- package/dist/guidance/references/build/checks/guidance.mjs +71 -0
- package/dist/guidance/references/build/checks/import-pattern.mjs +69 -0
- package/dist/guidance/references/build/checks/members-pattern.mjs +71 -0
- package/dist/guidance/references/build/checks/onboarding-pattern.mjs +54 -0
- package/dist/guidance/references/build/checks/pattern-types.mjs +13 -0
- package/dist/guidance/references/build/checks/pattern-workflows.mjs +226 -0
- package/dist/guidance/references/build/checks/primary-scope.mjs +49 -0
- package/dist/guidance/references/build/checks/render-tells.mjs +72 -0
- package/dist/guidance/references/build/checks/screen-layout-pattern.mjs +111 -0
- package/dist/guidance/references/build/checks/semantics.mjs +72 -0
- package/dist/guidance/references/build/checks/settings-pattern.mjs +73 -0
- package/dist/guidance/references/build/checks/sync-pattern.mjs +62 -0
- package/dist/guidance/references/build/checks/tells-lint.mjs +164 -0
- package/dist/guidance/references/build/fixtures/pattern-workflows.tsx +83 -0
- package/dist/guidance/references/build/fixtures/patterns/agent-action.jsx +60 -0
- package/dist/guidance/references/build/fixtures/patterns/app-skeleton.jsx +94 -0
- package/dist/guidance/references/build/fixtures/patterns/assistant-conversation.jsx +79 -0
- package/dist/guidance/references/build/fixtures/patterns/audit-log.jsx +81 -0
- package/dist/guidance/references/build/fixtures/patterns/auth.jsx +150 -0
- package/dist/guidance/references/build/fixtures/patterns/billing.jsx +96 -0
- package/dist/guidance/references/build/fixtures/patterns/developer-settings.jsx +95 -0
- package/dist/guidance/references/build/fixtures/patterns/import.jsx +102 -0
- package/dist/guidance/references/build/fixtures/patterns/members.jsx +106 -0
- package/dist/guidance/references/build/fixtures/patterns/onboarding.jsx +70 -0
- package/dist/guidance/references/build/fixtures/patterns/screen-layout-decision.jsx +74 -0
- package/dist/guidance/references/build/fixtures/patterns/screen-layout-editor.jsx +56 -0
- package/dist/guidance/references/build/fixtures/patterns/screen-layout-report.jsx +42 -0
- package/dist/guidance/references/build/fixtures/patterns/settings.jsx +73 -0
- package/dist/guidance/references/build/fixtures/patterns/sync.jsx +78 -0
- package/dist/guidance/references/project/components/src/pattern-async-collection.tsx +57 -0
- package/dist/guidance/references/project/components/src/pattern-consequential-action.tsx +77 -0
- package/dist/guidance/references/project/components/src/pattern-edit-record.tsx +127 -0
- package/dist/guidance/references/project/components/src/pattern-review-submit.tsx +47 -0
- package/dist/guidance/references/project/components/src/settings.tsx +79 -0
- package/dist/guidance/references/scripts/build-framework-fixture.mjs +20 -0
- package/dist/guidance/rules.json +653 -0
- package/dist/guidance/visual/Elevation.md +194 -0
- package/dist/guidance/visual/Fundamentals.md +83 -0
- package/dist/guidance/visual/Research.md +1620 -0
- package/dist/index.cjs +18795 -1925
- package/dist/index.cjs.map +4 -4
- package/dist/index.js +18795 -1925
- package/dist/index.js.map +4 -4
- package/dist/tokens.css +84 -84
- package/dist/tokens.json +301 -128
- package/dist/types/adapter-codemirror.d.ts +13 -0
- package/dist/types/agent-fleet.d.ts +166 -0
- package/dist/types/ai-label.d.ts +30 -0
- package/dist/types/app-shell.d.ts +83 -0
- package/dist/types/area-chart.d.ts +80 -0
- package/dist/types/artifact.d.ts +65 -0
- package/dist/types/bar-chart.d.ts +83 -0
- package/dist/types/bar-list.d.ts +35 -0
- package/dist/types/breadcrumb.d.ts +62 -0
- package/dist/types/chart-brush.d.ts +35 -0
- package/dist/types/chart-themes.d.ts +229 -0
- package/dist/types/chart.d.ts +570 -0
- package/dist/types/checklist.d.ts +71 -0
- package/dist/types/code-block.d.ts +32 -0
- package/dist/types/collections.d.ts +111 -4
- package/dist/types/color-picker.d.ts +38 -0
- package/dist/types/command.d.ts +6 -3
- package/dist/types/comment-thread.d.ts +48 -0
- package/dist/types/confirmation.d.ts +60 -0
- package/dist/types/context-menu.d.ts +17 -0
- package/dist/types/controls.d.ts +100 -17
- package/dist/types/conversation.d.ts +130 -0
- package/dist/types/copy-button.d.ts +19 -0
- package/dist/types/core.d.ts +139 -10
- package/dist/types/data-grid.d.ts +76 -0
- package/dist/types/date-picker.d.ts +96 -0
- package/dist/types/diff-view.d.ts +54 -0
- package/dist/types/dot-plot.d.ts +54 -0
- package/dist/types/edit-region.d.ts +121 -0
- package/dist/types/feature-gate.d.ts +22 -0
- package/dist/types/filter-bar.d.ts +65 -0
- package/dist/types/formatted-number.d.ts +38 -0
- package/dist/types/heatmap.d.ts +64 -0
- package/dist/types/in-place.d.ts +24 -0
- package/dist/types/index.d.ts +158 -15
- package/dist/types/inline-citation.d.ts +35 -0
- package/dist/types/inline-edit.d.ts +71 -0
- package/dist/types/input-otp.d.ts +37 -0
- package/dist/types/item-group.d.ts +104 -0
- package/dist/types/line-chart.d.ts +116 -0
- package/dist/types/link.d.ts +11 -0
- package/dist/types/markdown.d.ts +88 -0
- package/dist/types/meter.d.ts +43 -0
- package/dist/types/modal-state.d.ts +62 -0
- package/dist/types/nav.d.ts +8 -1
- package/dist/types/notifications.d.ts +42 -0
- package/dist/types/number-input.d.ts +30 -0
- package/dist/types/overlays.d.ts +51 -17
- package/dist/types/page-header.d.ts +27 -0
- package/dist/types/pagination.d.ts +29 -0
- package/dist/types/palette.d.ts +64 -0
- package/dist/types/password-input.d.ts +22 -0
- package/dist/types/pattern-assistant-conversation.d.ts +55 -0
- package/dist/types/pattern-async-collection.d.ts +34 -0
- package/dist/types/pattern-consequential-action.d.ts +53 -0
- package/dist/types/pattern-edit-record.d.ts +72 -0
- package/dist/types/pattern-review-submit.d.ts +43 -0
- package/dist/types/permission-matrix.d.ts +34 -0
- package/dist/types/plan-picker.d.ts +40 -0
- package/dist/types/prompt-input.d.ts +106 -0
- package/dist/types/property-list.d.ts +67 -0
- package/dist/types/question.d.ts +64 -0
- package/dist/types/relative-time.d.ts +21 -0
- package/dist/types/removal-receipt.d.ts +1180 -0
- package/dist/types/resizable.d.ts +48 -0
- package/dist/types/roving.d.ts +44 -0
- package/dist/types/save-bar.d.ts +38 -0
- package/dist/types/saved-views.d.ts +72 -0
- package/dist/types/scatter-chart.d.ts +60 -0
- package/dist/types/schedule.d.ts +80 -0
- package/dist/types/secret-field.d.ts +33 -0
- package/dist/types/select.d.ts +44 -2
- package/dist/types/separator.d.ts +11 -0
- package/dist/types/settings.d.ts +39 -0
- package/dist/types/sheet.d.ts +1 -1
- package/dist/types/sparkline.d.ts +46 -0
- package/dist/types/split-button.d.ts +21 -0
- package/dist/types/stat.d.ts +54 -0
- package/dist/types/stepper.d.ts +6 -3
- package/dist/types/suggestions.d.ts +29 -0
- package/dist/types/suspense.d.ts +12 -1
- package/dist/types/sync-indicator.d.ts +24 -0
- package/dist/types/tag-input.d.ts +65 -0
- package/dist/types/terminal.d.ts +33 -0
- package/dist/types/thread-list.d.ts +58 -0
- package/dist/types/time-input.d.ts +33 -0
- package/dist/types/timeline.d.ts +38 -0
- package/dist/types/toggle.d.ts +46 -0
- package/dist/types/toolbar.d.ts +34 -0
- package/dist/types/tour.d.ts +23 -0
- package/dist/types/trace.d.ts +65 -0
- package/dist/types/tree.d.ts +71 -0
- package/dist/types-cjs/adapter-codemirror.d.cts +13 -0
- package/dist/types-cjs/agent-fleet.d.cts +166 -0
- package/dist/types-cjs/ai-label.d.cts +30 -0
- package/dist/types-cjs/app-shell.d.cts +83 -0
- package/dist/types-cjs/area-chart.d.cts +80 -0
- package/dist/types-cjs/artifact.d.cts +65 -0
- package/dist/types-cjs/bar-chart.d.cts +83 -0
- package/dist/types-cjs/bar-list.d.cts +35 -0
- package/dist/types-cjs/breadcrumb.d.cts +62 -0
- package/dist/types-cjs/chart-brush.d.cts +35 -0
- package/dist/types-cjs/chart-themes.d.cts +229 -0
- package/dist/types-cjs/chart.d.cts +570 -0
- package/dist/types-cjs/checklist.d.cts +71 -0
- package/dist/types-cjs/code-block.d.cts +32 -0
- package/dist/types-cjs/collections.d.cts +246 -0
- package/dist/types-cjs/color-picker.d.cts +38 -0
- package/dist/types-cjs/command.d.cts +66 -0
- package/dist/types-cjs/comment-thread.d.cts +48 -0
- package/dist/types-cjs/confirmation.d.cts +60 -0
- package/dist/types-cjs/context-menu.d.cts +17 -0
- package/dist/types-cjs/controls.d.cts +360 -0
- package/dist/types-cjs/conversation.d.cts +130 -0
- package/dist/types-cjs/copy-button.d.cts +19 -0
- package/dist/types-cjs/core.d.cts +256 -0
- package/dist/types-cjs/data-grid.d.cts +76 -0
- package/dist/types-cjs/date-picker.d.cts +96 -0
- package/dist/types-cjs/diff-view.d.cts +54 -0
- package/dist/types-cjs/dot-plot.d.cts +54 -0
- package/dist/types-cjs/edit-region.d.cts +121 -0
- package/dist/types-cjs/empty.d.cts +24 -0
- package/dist/types-cjs/feature-gate.d.cts +22 -0
- package/dist/types-cjs/filter-bar.d.cts +65 -0
- package/dist/types-cjs/formatted-number.d.cts +38 -0
- package/dist/types-cjs/heatmap.d.cts +64 -0
- package/dist/types-cjs/in-place.d.cts +24 -0
- package/dist/types-cjs/index.d.cts +154 -0
- package/dist/types-cjs/inline-citation.d.cts +35 -0
- package/dist/types-cjs/inline-edit.d.cts +71 -0
- package/dist/types-cjs/input-otp.d.cts +37 -0
- package/dist/types-cjs/item-group.d.cts +104 -0
- package/dist/types-cjs/line-chart.d.cts +116 -0
- package/dist/types-cjs/link.d.cts +11 -0
- package/dist/types-cjs/markdown.d.cts +88 -0
- package/dist/types-cjs/meter.d.cts +43 -0
- package/dist/types-cjs/modal-state.d.cts +62 -0
- package/dist/types-cjs/nav.d.cts +25 -0
- package/dist/types-cjs/notifications.d.cts +42 -0
- package/dist/types-cjs/number-input.d.cts +30 -0
- package/dist/types-cjs/overlays.d.cts +170 -0
- package/dist/types-cjs/page-header.d.cts +27 -0
- package/dist/types-cjs/pagination.d.cts +29 -0
- package/dist/types-cjs/palette.d.cts +64 -0
- package/dist/types-cjs/password-input.d.cts +22 -0
- package/dist/types-cjs/pattern-assistant-conversation.d.cts +55 -0
- package/dist/types-cjs/pattern-async-collection.d.cts +34 -0
- package/dist/types-cjs/pattern-consequential-action.d.cts +53 -0
- package/dist/types-cjs/pattern-edit-record.d.cts +72 -0
- package/dist/types-cjs/pattern-review-submit.d.cts +43 -0
- package/dist/types-cjs/permission-matrix.d.cts +34 -0
- package/dist/types-cjs/pile.d.cts +27 -0
- package/dist/types-cjs/plan-picker.d.cts +40 -0
- package/dist/types-cjs/prompt-input.d.cts +106 -0
- package/dist/types-cjs/property-list.d.cts +67 -0
- package/dist/types-cjs/question.d.cts +64 -0
- package/dist/types-cjs/relative-time.d.cts +21 -0
- package/dist/types-cjs/removal-receipt.d.cts +1180 -0
- package/dist/types-cjs/resizable.d.cts +48 -0
- package/dist/types-cjs/roving.d.cts +44 -0
- package/dist/types-cjs/save-bar.d.cts +38 -0
- package/dist/types-cjs/saved-views.d.cts +72 -0
- package/dist/types-cjs/scatter-chart.d.cts +60 -0
- package/dist/types-cjs/schedule.d.cts +80 -0
- package/dist/types-cjs/secret-field.d.cts +33 -0
- package/dist/types-cjs/select.d.cts +109 -0
- package/dist/types-cjs/separator.d.cts +11 -0
- package/dist/types-cjs/settings.d.cts +39 -0
- package/dist/types-cjs/sheet.d.cts +27 -0
- package/dist/types-cjs/sparkline.d.cts +46 -0
- package/dist/types-cjs/split-button.d.cts +21 -0
- package/dist/types-cjs/stat.d.cts +54 -0
- package/dist/types-cjs/stepper.d.cts +26 -0
- package/dist/types-cjs/suggestions.d.cts +29 -0
- package/dist/types-cjs/suspense.d.cts +85 -0
- package/dist/types-cjs/sync-indicator.d.cts +24 -0
- package/dist/types-cjs/tag-input.d.cts +65 -0
- package/dist/types-cjs/terminal.d.cts +33 -0
- package/dist/types-cjs/thread-list.d.cts +58 -0
- package/dist/types-cjs/time-input.d.cts +33 -0
- package/dist/types-cjs/timeline.d.cts +38 -0
- package/dist/types-cjs/toggle.d.cts +46 -0
- package/dist/types-cjs/toolbar.d.cts +34 -0
- package/dist/types-cjs/tour.d.cts +23 -0
- package/dist/types-cjs/trace.d.cts +65 -0
- package/dist/types-cjs/tree.d.cts +71 -0
- package/package.json +89 -9
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Settings pattern: every section is a region named by its heading; a row's label and description name and describe its
|
|
2
|
+
// control; an apply-now switch hovers while its write is pending, lands with "Saved", and a failed write drops into a
|
|
3
|
+
// trench, says so and puts the switch back; a read-only section disables its rows and says why; the danger zone is the
|
|
4
|
+
// same height as the other sections with a critical rim; general fields join one draft that the SaveBar saves; deleting
|
|
5
|
+
// needs the workspace's name typed into a stationary dialog. Run after a render of every preview (it opens
|
|
6
|
+
// test/p-SettingsPattern-daylight.html); exits non-zero on a failure.
|
|
7
|
+
import { chromium } from 'playwright-core';
|
|
8
|
+
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
|
|
9
|
+
const T = new URL('../test/', import.meta.url).pathname;
|
|
10
|
+
let pass = 0, fail = 0;
|
|
11
|
+
const expect = (c, m) => { if (c) pass++; else { fail++; console.log('FAIL', m); } };
|
|
12
|
+
const page = await browser.newPage({ viewport: { width: 960, height: 900 } });
|
|
13
|
+
await page.goto(`file://${T}p-SettingsPattern-daylight.html`);
|
|
14
|
+
await page.waitForTimeout(400);
|
|
15
|
+
|
|
16
|
+
const regions = await page.evaluate(() => [...document.querySelectorAll('.alt-settings-section')].map((s) => document.getElementById(s.getAttribute('aria-labelledby')).textContent));
|
|
17
|
+
expect(regions.join() === 'General,Notifications,Billing,Danger zone', `each section is named by its heading (${regions})`);
|
|
18
|
+
const digest = page.getByRole('switch', { name: 'Weekly digest' });
|
|
19
|
+
expect(await digest.count() === 1, 'the row label names its switch');
|
|
20
|
+
expect(await digest.evaluate((b) => b.getAttribute('aria-describedby').split(' ').map((id) => document.getElementById(id).textContent).join()) === 'A summary of activity every Monday.', 'and the description describes it');
|
|
21
|
+
|
|
22
|
+
const row = (name) => page.evaluate((name) => { const r = [...document.querySelectorAll('.alt-settings-row')].find((r) => r.querySelector('.alt-settings-label').textContent === name);
|
|
23
|
+
return { status: r.dataset.status, words: r.querySelector('.alt-settings-status').textContent, checked: r.querySelector('[role="switch"]')?.getAttribute('aria-checked') }; }, name);
|
|
24
|
+
await digest.click();
|
|
25
|
+
await page.waitForTimeout(450);
|
|
26
|
+
let r = await row('Weekly digest');
|
|
27
|
+
expect(r.status === 'pending' && r.words === 'Saving…' && r.checked === 'false', `an apply-now change hovers while it saves (${JSON.stringify(r)})`);
|
|
28
|
+
expect(await page.evaluate(() => getComputedStyle(document.querySelector('.alt-settings-row[data-status="pending"]')).animationName) === 'alt-hover', 'it hovers');
|
|
29
|
+
await page.waitForTimeout(800);
|
|
30
|
+
r = await row('Weekly digest');
|
|
31
|
+
expect(r.status === 'committed' && r.words === 'Saved' && r.checked === 'false', `then lands, saved (${JSON.stringify(r)})`);
|
|
32
|
+
await page.getByRole('switch', { name: 'Make the next change fail' }).click();
|
|
33
|
+
await page.getByRole('switch', { name: 'Mentions' }).click();
|
|
34
|
+
await page.waitForTimeout(1300);
|
|
35
|
+
r = await row('Mentions');
|
|
36
|
+
expect(r.status === 'failed' && /Couldn’t save/.test(r.words) && r.checked === 'true', `a failed write drops, says so and is put back (${JSON.stringify(r)})`);
|
|
37
|
+
await page.getByRole('switch', { name: 'Make the next change fail' }).click();
|
|
38
|
+
|
|
39
|
+
const billing = await page.evaluate(() => { const s = document.getElementById('st-billing'), f = s.querySelector('fieldset');
|
|
40
|
+
return { disabled: s.querySelector('input').matches(':disabled'), reason: document.getElementById(f.getAttribute('aria-describedby')).textContent }; });
|
|
41
|
+
expect(billing.disabled && /Only workspace owners/.test(billing.reason), `a read-only section disables its rows and says why (${JSON.stringify(billing)})`);
|
|
42
|
+
const danger = await page.evaluate(() => { const d = document.getElementById('st-danger'), g = document.getElementById('st-general'), cs = getComputedStyle(d);
|
|
43
|
+
return { tone: d.dataset.tone, inset: /inset/.test(cs.boxShadow), same: cs.transform === getComputedStyle(g).transform && cs.zIndex === getComputedStyle(g).zIndex }; });
|
|
44
|
+
expect(danger.tone === 'critical' && danger.inset && danger.same, `the danger zone has a critical rim at the same height (${JSON.stringify(danger)})`);
|
|
45
|
+
|
|
46
|
+
await page.fill('#ws-name', 'Acme Labs');
|
|
47
|
+
await page.waitForTimeout(900);
|
|
48
|
+
expect(await page.locator('.alt-savebar[data-state="open"], .alt-savebar:not([data-state="closed"])').count() === 1, 'a changed field opens the SaveBar');
|
|
49
|
+
await page.fill('#ws-url', 'Acme Labs');
|
|
50
|
+
await page.getByRole('button', { name: 'Save changes' }).click();
|
|
51
|
+
await page.waitForTimeout(300);
|
|
52
|
+
expect(await page.evaluate(() => document.activeElement.closest('.alt-error-summary') !== null), 'an invalid save focuses the error summary');
|
|
53
|
+
await page.fill('#ws-url', 'acme-labs');
|
|
54
|
+
await page.getByRole('button', { name: 'Save changes' }).click();
|
|
55
|
+
await page.waitForTimeout(1400);
|
|
56
|
+
expect(await page.inputValue('#ws-name') === 'Acme Labs' && await page.evaluate(() => !document.querySelector('.alt-savebar') || document.querySelector('.alt-savebar').dataset.state === 'closed'), 'saving lowers the bar and keeps the values');
|
|
57
|
+
|
|
58
|
+
await page.getByRole('button', { name: 'Delete workspace' }).click();
|
|
59
|
+
await page.waitForTimeout(500);
|
|
60
|
+
const dialog = page.getByRole('dialog', { name: 'Delete Acme workspace?' });
|
|
61
|
+
expect(await dialog.count() === 1, 'deleting asks in a dialog');
|
|
62
|
+
expect(await page.evaluate(() => !document.querySelector('[data-receded]')), 'the dialog is stationary: nothing behind it recedes');
|
|
63
|
+
const confirm = dialog.getByRole('button', { name: 'Delete workspace' });
|
|
64
|
+
expect(await confirm.isDisabled(), 'confirming waits for the name');
|
|
65
|
+
await dialog.getByLabel('Type Acme to confirm').fill('Acme');
|
|
66
|
+
expect(!(await confirm.isDisabled()), 'typing the name enables it');
|
|
67
|
+
await confirm.click();
|
|
68
|
+
await page.waitForTimeout(1300);
|
|
69
|
+
expect(await page.getByRole('heading', { name: 'Workspace deleted' }).count() === 1, 'and only a completed delete says so');
|
|
70
|
+
|
|
71
|
+
console.log(`Settings pattern: ${pass} passed, ${fail} failed`);
|
|
72
|
+
if (fail) process.exitCode = 1;
|
|
73
|
+
await browser.close();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Sync pattern: a change hovers as pending until the server acknowledges it, then lands, and the indicator says Saved
|
|
2
|
+
// only then; offline, changes wait ("Waiting to sync"), the indicator counts them and nothing claims to be saved; going
|
|
3
|
+
// online sends them; a conflict is counted by the indicator, which takes you to it, shows both versions (theirs with
|
|
4
|
+
// their presence rim and name) and keeps the chosen one, saying what happened to the other; an expiring session asks in
|
|
5
|
+
// a stationary dialog that Escape doesn't close and says the draft is kept. Run after a render of every preview (it opens
|
|
6
|
+
// test/p-SyncPattern-daylight.html); exits non-zero on a failure.
|
|
7
|
+
import { chromium } from 'playwright-core';
|
|
8
|
+
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
|
|
9
|
+
const T = new URL('../test/', import.meta.url).pathname;
|
|
10
|
+
let pass = 0, fail = 0;
|
|
11
|
+
const expect = (c, m) => { if (c) pass++; else { fail++; console.log('FAIL', m); } };
|
|
12
|
+
const page = await browser.newPage({ viewport: { width: 960, height: 800 } });
|
|
13
|
+
await page.goto(`file://${T}p-SyncPattern-daylight.html`);
|
|
14
|
+
await page.waitForTimeout(400);
|
|
15
|
+
const indicator = () => page.evaluate(() => [...document.querySelectorAll('.alt-sync > :not(.alt-sr)')].map((n) => n.textContent).join(' '));
|
|
16
|
+
const task = (name) => page.evaluate((name) => { const s = [...document.querySelectorAll('.sy-task')].find((t) => t.textContent.includes(name)); return { status: s.dataset.status || '', words: s.querySelector('.sy-state').textContent }; }, name);
|
|
17
|
+
|
|
18
|
+
await page.getByText('Book the venue').click();
|
|
19
|
+
await page.waitForTimeout(150);
|
|
20
|
+
let t = await task('Book the venue');
|
|
21
|
+
expect(t.status === 'pending' && t.words === 'Saving…' && !/Saved/.test(await indicator()), `a change is pending until acknowledged (${JSON.stringify(t)}, ${await indicator()})`);
|
|
22
|
+
await page.waitForTimeout(800);
|
|
23
|
+
t = await task('Book the venue');
|
|
24
|
+
expect(t.status === 'committed' && /Saved/.test(await indicator()), `then lands, and only then is Saved said (${await indicator()})`);
|
|
25
|
+
|
|
26
|
+
await page.getByRole('switch', { name: 'Online' }).click();
|
|
27
|
+
await page.getByText('Send the press kit').click();
|
|
28
|
+
await page.getByText('Draft the launch email').click();
|
|
29
|
+
await page.waitForTimeout(1100);
|
|
30
|
+
const a = await task('Send the press kit'), b = await task('Draft the launch email'), ind = await indicator();
|
|
31
|
+
expect(a.status === 'pending' && b.status === 'pending' && a.words === 'Waiting to sync', `offline, changes wait (${JSON.stringify(a)})`);
|
|
32
|
+
expect(/Offline/i.test(ind) && /2/.test(ind) && !/Saved/.test(ind), `the indicator counts them and claims nothing (${ind})`);
|
|
33
|
+
await page.getByRole('switch', { name: 'Online' }).click();
|
|
34
|
+
await page.waitForTimeout(900);
|
|
35
|
+
expect((await task('Send the press kit')).status === 'committed' && (await task('Draft the launch email')).status === 'committed' && /Saved/.test(await indicator()), 'going online sends them');
|
|
36
|
+
|
|
37
|
+
await page.getByRole('button', { name: 'Simulate a conflict' }).click();
|
|
38
|
+
await page.waitForTimeout(300);
|
|
39
|
+
expect(/1 conflict/.test(await indicator()), 'the indicator counts the conflict');
|
|
40
|
+
await page.locator('.alt-sync').getByRole('button').last().click();
|
|
41
|
+
await page.waitForTimeout(200);
|
|
42
|
+
expect(await page.evaluate(() => document.activeElement.id) === 'sy-conflict-h', 'and takes you to it');
|
|
43
|
+
const theirs = await page.evaluate(() => { const v = document.querySelector('.sy-version[data-presence]'); return { name: v.textContent, rim: getComputedStyle(v).boxShadow }; });
|
|
44
|
+
expect(/Jane Park’s version/.test(theirs.name) && /inset/.test(theirs.rim), 'their version wears their presence rim and name');
|
|
45
|
+
await page.getByRole('button', { name: 'Keep theirs' }).click();
|
|
46
|
+
await page.waitForTimeout(300);
|
|
47
|
+
expect(await page.evaluate(() => document.querySelector('.sy-brief').textContent) === 'Launch on 11 December, in Porto.' && /Yours is in the history/.test(await page.evaluate(() => document.querySelector('.sy-note').textContent)), 'the chosen version is kept, and the other is never silently dropped');
|
|
48
|
+
|
|
49
|
+
await page.getByRole('button', { name: 'Simulate session expiry' }).click();
|
|
50
|
+
await page.waitForTimeout(500);
|
|
51
|
+
const dialog = page.getByRole('dialog', { name: 'Your session ends in 2 minutes' });
|
|
52
|
+
expect(await dialog.count() === 1 && /kept on this device/.test(await dialog.textContent()), 'an expiring session asks, with the time in words, and keeps the draft');
|
|
53
|
+
await page.keyboard.press('Escape');
|
|
54
|
+
await page.waitForTimeout(300);
|
|
55
|
+
expect(await dialog.count() === 1 && await page.locator('[data-receded]').count() === 0, 'it is stationary and Escape doesn’t dismiss it');
|
|
56
|
+
await dialog.getByRole('button', { name: 'Stay signed in' }).click();
|
|
57
|
+
await page.waitForTimeout(400);
|
|
58
|
+
expect(await page.getByRole('dialog').count() === 0, 'Stay signed in closes it');
|
|
59
|
+
|
|
60
|
+
console.log(`Sync pattern: ${pass} passed, ${fail} failed`);
|
|
61
|
+
if (fail) process.exitCode = 1;
|
|
62
|
+
await browser.close();
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The tells of generated interfaces that are certain from source text (Distinctiveness, #320). Every `lint` tell in
|
|
2
|
+
// project/tells.json has a rule here, and every rule names a tell there, so the two can't drift apart.
|
|
3
|
+
// Scans the components' sources and styles, the previews, the tokens, the landing page and the example apps. No browser, no network.
|
|
4
|
+
// node checks/tells-lint.mjs exits non-zero on a finding
|
|
5
|
+
// node checks/tells-lint.mjs --json the findings as JSON
|
|
6
|
+
// node checks/tells-lint.mjs --self-test only the rules' own test (it also runs first on every scan): each catches its
|
|
7
|
+
// example and passes its counter-example
|
|
8
|
+
// An intentional exception is a comment on the line, or the line above: `tells-ignore: <id> <reason>`.
|
|
9
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { join, relative } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const root = fileURLToPath(new URL('../../', import.meta.url));
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
|
|
16
|
+
/* ── colour helpers ── */
|
|
17
|
+
const hexRe = /#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/gi;
|
|
18
|
+
function hsl(hex) {
|
|
19
|
+
let h = hex.slice(1);
|
|
20
|
+
if (h.length <= 4) h = [...h].map((c) => c + c).join('');
|
|
21
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
|
22
|
+
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
|
23
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, d = max - min;
|
|
24
|
+
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
|
25
|
+
let hue = 0;
|
|
26
|
+
if (d) hue = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
27
|
+
return { h: (hue * 60 + 360) % 360, s, l, a };
|
|
28
|
+
}
|
|
29
|
+
const isPurple = (hex) => { const c = hsl(hex); return c.s > 0.3 && c.h >= 250 && c.h <= 300; };
|
|
30
|
+
const MEANING = /var\(--(?:accent|positive|caution|critical|data-|presence-)[\w-]*\)/;
|
|
31
|
+
const loud = (color) => MEANING.test(color) || (color.match(hexRe) ?? []).some((h) => { const c = hsl(h); return c.a > 0.15 && (c.s > 0.35 || c.l > 0.7); });
|
|
32
|
+
|
|
33
|
+
// Tailwind's default palette, a sample of the values generators reach for most. Extend it as reviews find more.
|
|
34
|
+
const KIT_HEX = ['#3b82f6', '#2563eb', '#1d4ed8', '#6366f1', '#4f46e5', '#8b5cf6', '#7c3aed', '#a855f7', '#9333ea', '#ec4899', '#db2777', '#be185d',
|
|
35
|
+
'#f43f5e', '#ef4444', '#dc2626', '#f97316', '#ea580c', '#c2410c', '#f59e0b', '#eab308', '#22c55e', '#16a34a', '#10b981', '#059669', '#14b8a6',
|
|
36
|
+
'#0d9488', '#06b6d4', '#0ea5e9', '#0f172a', '#1e293b', '#64748b', '#111827', '#18181b'];
|
|
37
|
+
const KIT_CLASS = /\b(?:bg|text|border|from|via|to|ring|fill|stroke)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/;
|
|
38
|
+
|
|
39
|
+
/* ── font helpers ── */
|
|
40
|
+
const familyDecl = /(?:font-family:|--font-[\w-]+:|"(?:sans|mono|display)":)\s*([^;}\n]+)/;
|
|
41
|
+
const firstFamily = (line) => { const m = line.match(familyDecl); return m ? m[1].split(',')[0].replace(/[\\"']/g, '').trim().toLowerCase() : null; };
|
|
42
|
+
const DEFAULT_FONTS = ['inter', 'roboto', 'arial', 'helvetica', 'helvetica neue'];
|
|
43
|
+
// TY5: a family is loaded if a Google Fonts import or an @font-face in the scanned files names it, or it's a system or
|
|
44
|
+
// generic family. Filled in once the files are known (below).
|
|
45
|
+
const LOADED = new Set(['system-ui', '-apple-system', 'blinkmacsystemfont', 'segoe ui', 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'sf mono', 'sfmono-regular',
|
|
46
|
+
'menlo', 'consolas', 'monaco', 'sans-serif', 'serif', 'monospace', 'cursive', 'inherit', 'initial', 'unset', 'currentcolor']);
|
|
47
|
+
const loaders = (text) => [...[...text.matchAll(/fonts\.googleapis\.com\/css2?\?[^'")\s]*/g)].flatMap((m) => [...m[0].matchAll(/family=([^:&]+)/g)].map((f) => decodeURIComponent(f[1]).replace(/\+/g, ' ').toLowerCase())),
|
|
48
|
+
...[...text.matchAll(/@font-face\s*{[^}]*font-family:\s*["']?([^;"']+)/g)].map((m) => m[1].trim().toLowerCase())];
|
|
49
|
+
const unloaded = (l) => { const f = firstFamily(l); return !!f && !f.startsWith('var(') && !LOADED.has(f); };
|
|
50
|
+
const TASTEFUL_FONTS = ['space grotesk', 'geist', 'instrument serif', 'fraunces'];
|
|
51
|
+
|
|
52
|
+
/* ── shadow and border helpers ── */
|
|
53
|
+
const layers = (value) => { const out = []; let depth = 0, cur = ''; for (const ch of value) { if (ch === '(') depth++; if (ch === ')') depth--; if (ch === ',' && !depth) { out.push(cur); cur = ''; } else cur += ch; } out.push(cur); return out; };
|
|
54
|
+
function glow(line) {
|
|
55
|
+
const m = line.match(/(?:box-shadow|text-shadow):\s*([^;}]+)/) ?? line.match(/drop-shadow\(([^)]*\)?[^)]*)\)/);
|
|
56
|
+
if (!m) return false;
|
|
57
|
+
return layers(m[1]).some((layer) => {
|
|
58
|
+
if (/\binset\b/.test(layer)) return false;
|
|
59
|
+
const lengths = layer.replace(/var\([^)]*\)|rgba?\([^)]*\)|#[0-9a-f]+/gi, ' ').trim().split(/\s+/).filter((x) => /^-?\d*\.?\d+(px)?$/.test(x)).map(parseFloat);
|
|
60
|
+
return (lengths[2] ?? 0) >= 8 && loud(layer);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function stripe(line) {
|
|
64
|
+
const m = line.match(/border-(?:left|top|inline-start|block-start)(?:-color)?:\s*([^;}]+)/);
|
|
65
|
+
if (!m) return false;
|
|
66
|
+
const width = +(m[1].match(/(\d*\.?\d+)px/)?.[1] ?? 2);
|
|
67
|
+
return width >= 2 && loud(m[1]);
|
|
68
|
+
}
|
|
69
|
+
function bounce(line) {
|
|
70
|
+
return [...line.matchAll(/cubic-bezier\(([^)]+)\)/g)].some((m) => { const [, y1, , y2] = m[1].split(',').map(Number); return [y1, y2].some((y) => y > 1.05 || y < -0.05); });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ── the rules, by tell id ── */
|
|
74
|
+
const CSS = /\.(css|txt|tsx|ts)$/, MARKUP = /\.(txt|tsx)$/, ALL = /./;
|
|
75
|
+
const rules = [
|
|
76
|
+
{ id: 'CO1', files: ALL, test: (l) => /\b(?:from|via|to|bg|text|border|ring)-(?:indigo|violet|purple)-\d{2,3}\b/.test(l) || (/gradient\(/.test(l) && (l.match(hexRe) ?? []).some(isPurple)),
|
|
77
|
+
bad: 'background: linear-gradient(90deg, #6d28d9, #3b82f6);', good: 'background: linear-gradient(to bottom, #ffffff0f, #00000008);' },
|
|
78
|
+
{ id: 'CO2', files: CSS, test: (l) => /background-clip:\s*text/.test(l), bad: '-webkit-background-clip: text;', good: 'background-clip: padding-box;' },
|
|
79
|
+
{ id: 'CO6', files: CSS, test: glow, bad: 'box-shadow: 0 0 24px 4px var(--accent);', good: 'box-shadow: inset 0 1px 0 #ffffff12, 0 2px 6px -1px var(--alt-cast);' },
|
|
80
|
+
{ id: 'CO8', files: ALL, test: (l) => KIT_CLASS.test(l) || KIT_HEX.some((h) => l.toLowerCase().includes(h)), bad: "{ hex: '#c2410c' }", good: "{ hex: '#2a6f9e' }" },
|
|
81
|
+
{ id: 'TY1', files: ALL, test: (l) => DEFAULT_FONTS.includes(firstFamily(l)), bad: 'font-family: Inter, system-ui, sans-serif;', good: 'font-family: var(--font-sans);' },
|
|
82
|
+
{ id: 'TY2', files: ALL, test: (l) => TASTEFUL_FONTS.includes(firstFamily(l)), bad: '--font-display: "Space Grotesk", sans-serif;', good: '--font-display: "Instrument Sans", sans-serif;' },
|
|
83
|
+
{ id: 'TY5', files: ALL, test: unloaded, bad: '--font-display: "Recoleta", serif;', good: '--font-sans: "Instrument Sans", system-ui, sans-serif;' },
|
|
84
|
+
{ id: 'TY4', files: CSS, test: (l) => /text-transform:\s*uppercase/.test(l), bad: '.kicker { text-transform: uppercase; letter-spacing: 0.06em; }', good: '.label { text-transform: none; }' },
|
|
85
|
+
{ id: 'LA8', files: MARKUP, test: (l) => /<(?:span|p|div|small|strong)\b[^>]*>\s*0[1-9]\.?\s*<\//.test(l), bad: '<span className="step">02</span>', good: '<span className="step">Two</span>' },
|
|
86
|
+
{ id: 'CM2', files: CSS, test: (l) => /backdrop-filter:(?!\s*none)/.test(l), bad: '.panel { backdrop-filter: blur(12px); }', good: '.panel { backdrop-filter: none; }' },
|
|
87
|
+
{ id: 'CM3', files: CSS, test: stripe, bad: '.card { border-left: 4px solid var(--accent); }', good: '.cell { border-left: 1px solid var(--line); }' },
|
|
88
|
+
{ id: 'CM5', files: MARKUP, test: (l) => /\b(?:BorderBeam|ShimmerButton|Meteors|Spotlight|AuroraBackground|BackgroundBeams|SparklesCore|Marquee)\b/.test(l), bad: '<BorderBeam size={250} />', good: '<Card>' },
|
|
89
|
+
{ id: 'CM8', files: ALL, test: (l) => /#(?:ff5f57|febc2e|28c840|ffbd2e|27c93f)\b/i.test(l), bad: '.dot-red { background: #ff5f57; }', good: '.dot { background: var(--line); }' },
|
|
90
|
+
{ id: 'MO1', files: ALL, test: (l) => /animation-timeline:\s*view\(|@keyframes\s+fade-?(?:in-?)?up\b/i.test(l), bad: '@keyframes fadeInUp { from { opacity: 0 } }', good: '@keyframes alt-rise { from { opacity: 0 } }' },
|
|
91
|
+
{ id: 'MO2', files: ALL, test: bounce, bad: 'transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);', good: 'transition: transform 160ms cubic-bezier(0.2, 0.9, 0.25, 1);' },
|
|
92
|
+
{ id: 'MO3', files: ALL, test: (l) => /count-?up/i.test(l), bad: '<CountUp end={1200} />', good: '<FormattedNumber value={1200} />' },
|
|
93
|
+
{ id: 'IC1', files: MARKUP, test: (l) => /\p{Emoji_Presentation}|️/u.test(l), bad: '<Button>🚀 Launch</Button>', good: '<Button>Launch</Button>' },
|
|
94
|
+
{ id: 'IC2', files: ALL, test: (l) => /sparkle/i.test(l), bad: '<SparkleIcon />', good: '<InfoIcon />' },
|
|
95
|
+
{ id: 'CP1', files: MARKUP, test: (l) => /\b(?:seamless(?:ly)?|supercharge[sd]?|unleash(?:es|ed)?|empower(?:s|ed|ing)?|effortless(?:ly)?|revolutioni[sz]e[sd]?|game-?changer|next-level|cutting-edge|world-class)\b/i.test(l),
|
|
96
|
+
bad: 'Seamlessly sync your files', good: 'Sync your files' },
|
|
97
|
+
{ id: 'CP2', files: MARKUP, test: (l) => /Welcome back!|Something went wrong|\bOops\b|>\s*(?:OK|Submit)\s*</.test(l), bad: '<Button>Submit</Button>', good: '<Button>Send invite</Button>' },
|
|
98
|
+
{ id: 'CP3', files: MARKUP, test: (l) => /<(Button|Link|a)\b[^>]*>[^<]*(?:→|->)\s*<\/\1>/.test(l), bad: '<Button>Get started →</Button>', good: '<Button>Create workspace</Button>' },
|
|
99
|
+
{ id: 'CP5', files: MARKUP, test: (l) => /<(Button|Link|a)\b[^>]*>\s*(?:Learn more|Read more|Click here|See more|More|Here)\s*<\/\1>/i.test(l), bad: '<Link href="/pricing">Learn more</Link>', good: '<Link href="/pricing">Compare plans</Link>' },
|
|
100
|
+
{ id: 'CP4', files: MARKUP, test: (l) => /\bAI-powered\b/i.test(l), bad: 'The AI-powered inbox for modern teams', good: 'Sort your inbox by who is waiting' },
|
|
101
|
+
{ id: 'CN1', files: /\.css$/, test: (l) => /(?:^|[{;\s])font-family:(?!\s*(?:var\(|inherit|initial|unset))/.test(l) && !/^\s*--/.test(l), bad: '.title { font-family: "Fraunces", serif; }', good: '.title { font-family: var(--font-sans); }' },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// Exceptions in files that can't hold a comment.
|
|
105
|
+
const EXCEPTIONS = [
|
|
106
|
+
{ file: 'project/tokens.json', id: 'MO2', includes: '"cubic-bezier(0.3, 1.3, 0.45, 1)"', reason: 'ease-settle: a documented 2–3% overshoot on thumbs and indicators (Motion), not a bounce.' },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
/* ── which files ── */
|
|
110
|
+
const walk = (dir, re) => readdirSync(join(root, dir), { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(join(dir, e.name), re) : re.test(e.name) ? [join(dir, e.name)] : []);
|
|
111
|
+
const files = [
|
|
112
|
+
'project/components/bundle.css', 'build/previews.txt', 'project/tokens.json',
|
|
113
|
+
...walk('project/components/src', /\.(ts|tsx)$/), ...walk('site', /\.(ts|tsx|css)$/), ...walk('examples/apps', /\.(ts|tsx|css)$/),
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
for (const f of files) for (const name of loaders(readFileSync(join(root, f), 'utf8'))) LOADED.add(name);
|
|
117
|
+
|
|
118
|
+
function scan(file, text) {
|
|
119
|
+
const lines = text.split('\n'), found = [];
|
|
120
|
+
lines.forEach((line, i) => {
|
|
121
|
+
for (const rule of rules) {
|
|
122
|
+
if (!rule.files.test(file) && !(file.endsWith('.json') && /^(MO2|TY1|TY2|TY5)$/.test(rule.id))) continue;
|
|
123
|
+
if (!rule.test(line)) continue;
|
|
124
|
+
const ignored = (s) => new RegExp(`tells-ignore:\\s*(?:[A-Z]{2}\\d+,\\s*)*${rule.id}\\s+[A-Za-z]`).test(s ?? '');
|
|
125
|
+
if (ignored(line) || ignored(lines[i - 1])) continue;
|
|
126
|
+
if (EXCEPTIONS.some((e) => e.file === file && e.id === rule.id && line.includes(e.includes))) continue;
|
|
127
|
+
found.push({ id: rule.id, file, line: i + 1, text: line.trim().slice(0, 140) });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return found;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ── the catalog and the rules agree ── */
|
|
134
|
+
const catalog = JSON.parse(readFileSync(join(root, 'project/tells.json'), 'utf8')).tells;
|
|
135
|
+
const lintIds = catalog.filter((t) => t.detect === 'lint').map((t) => t.id);
|
|
136
|
+
const problems = [];
|
|
137
|
+
for (const r of rules) if (!catalog.some((t) => t.id === r.id)) problems.push(`rule ${r.id} names no tell in project/tells.json`);
|
|
138
|
+
else if (!lintIds.includes(r.id)) problems.push(`rule ${r.id}: tells.json says ${r.id} is detected by ${catalog.find((t) => t.id === r.id).detect}, not lint`);
|
|
139
|
+
for (const id of lintIds) if (!rules.some((r) => r.id === id)) problems.push(`tells.json lists ${id} as lint, but no rule checks it`);
|
|
140
|
+
|
|
141
|
+
// The rules test themselves on every run, so a broken pattern fails instead of passing everything.
|
|
142
|
+
{
|
|
143
|
+
for (const r of rules) {
|
|
144
|
+
if (!r.test(r.bad)) problems.push(`${r.id}: missed its own example: ${r.bad}`);
|
|
145
|
+
if (r.test(r.good)) problems.push(`${r.id}: flagged a line it should pass: ${r.good}`);
|
|
146
|
+
}
|
|
147
|
+
if (scan('site/x.css', '.k { text-transform: uppercase; } /* tells-ignore: TY4 a printed label */').length !== 0) problems.push('tells-ignore with a reason was not honoured');
|
|
148
|
+
if (scan('site/x.css', '.k { text-transform: uppercase; } /* tells-ignore: TY4 */').length !== 1) problems.push('tells-ignore without a reason must not suppress');
|
|
149
|
+
if (args.includes('--self-test')) {
|
|
150
|
+
if (problems.length) { console.error(problems.join('\n')); process.exit(1); }
|
|
151
|
+
console.log(`tells-lint self-test: ${rules.length} rules, each catches its example and passes its counter-example`);
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const findings = problems.length ? [] : files.flatMap((f) => scan(f, readFileSync(join(root, f), 'utf8')));
|
|
157
|
+
if (args.includes('--json')) console.log(JSON.stringify({ problems, findings }, null, 2));
|
|
158
|
+
else {
|
|
159
|
+
for (const p of problems) console.error(`tells-lint: ${p}`);
|
|
160
|
+
for (const f of findings) console.error(`${f.id} ${relative(root, join(root, f.file))}:${f.line} ${f.text}`);
|
|
161
|
+
if (!problems.length && !findings.length) console.log(`tells-lint: ${files.length} files, ${rules.length} rules, no tells (project/tells.json, Distinctiveness)`);
|
|
162
|
+
else if (findings.length) console.error(`\n${findings.length} finding(s). Fix them, or mark an intentional one with \`tells-ignore: <id> <reason>\` (see project/Distinctiveness.md).`);
|
|
163
|
+
}
|
|
164
|
+
process.exit(problems.length || findings.length ? 1 : 0);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Runnable compositions. Import PatternWorkflows inside one AltitudeProvider.
|
|
2
|
+
* This controlled in-memory service demonstrates behavior, not server durability or authorization.
|
|
3
|
+
* Replace the adapters with your application operations and retain their accepted-result contracts.
|
|
4
|
+
*/
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import { Button, Field, Input, Dialog, ErrorSummary, WorkflowStatus, useEditRecord, useAsyncCollection,
|
|
7
|
+
useConsequentialAction, useReviewSubmit } from 'altitude-design';
|
|
8
|
+
import type { EditRecordResult } from 'altitude-design';
|
|
9
|
+
type RecordValue = { name: string };
|
|
10
|
+
export function EditRecordExample({ initialValue, save }: { initialValue: RecordValue; save: (value: RecordValue) => Promise<EditRecordResult<RecordValue>> }) {
|
|
11
|
+
const form = useEditRecord({ initialValue, save, validationMode: 'submit', validate: value => value.name.trim() ? {} : { name: 'Enter a name.' } });
|
|
12
|
+
const [left, setLeft] = React.useState(false);
|
|
13
|
+
const id = React.useId();
|
|
14
|
+
if (left) return <p>You left the editor.</p>;
|
|
15
|
+
return <section aria-label="Edit record"><h2>Edit workspace</h2><form noValidate onSubmit={event => { event.preventDefault(); void form.submit(); }}>
|
|
16
|
+
<ErrorSummary errors={form.errors.name ? [{ id, label: 'Workspace name', message: form.errors.name }] : []} focusKey={form.submitCount} />
|
|
17
|
+
<Field label="Workspace name" error={form.errors.name}><Input id={id} value={form.draft.name} onChange={event => form.setField('name', event.target.value)} onBlur={() => form.validateField('name')} /></Field>
|
|
18
|
+
<Button type="submit" loading={form.phase === 'submitting'}>Save changes</Button>
|
|
19
|
+
<Button type="button" disabled={form.phase === 'submitting'} onClick={async () => { if (await form.requestLeave('cancel', () => window.confirm('Discard your unsaved changes?'))) setLeft(true); }}>Cancel editing</Button>
|
|
20
|
+
</form><WorkflowStatus>{form.status}</WorkflowStatus></section>;
|
|
21
|
+
}
|
|
22
|
+
export function AsyncCollectionExample({ load }: { load: (query: string, context: { signal: AbortSignal }) => Promise<readonly { id: string; name: string }[]> }) {
|
|
23
|
+
const collection = useAsyncCollection({ initialQuery: '', load, getKey: item => item.id, isFiltered: query => !!query, selection: 'preserve' });
|
|
24
|
+
return <section aria-label="Search records"><h2>Records</h2>
|
|
25
|
+
<Field label="Search records"><Input value={collection.query} onChange={event => collection.setQuery(event.target.value)} /></Field>
|
|
26
|
+
<Button onClick={collection.reset}>Reset search</Button><Button onClick={collection.refresh}>Refresh records</Button>
|
|
27
|
+
<WorkflowStatus>{collection.status}</WorkflowStatus>
|
|
28
|
+
{collection.phase === 'failed' && <Button onClick={collection.refresh}>Retry loading</Button>}
|
|
29
|
+
<ul aria-busy={collection.phase === 'loading' || collection.phase === 'refreshing'}>{collection.items.map(item => <li key={item.id}>
|
|
30
|
+
<label><input type="checkbox" checked={collection.selectedKeys.has(item.id)} onChange={() => collection.toggleSelection(item.id)} />{item.name}</label>
|
|
31
|
+
</li>)}</ul><p>{collection.selectedKeys.size} selected, including records outside this view.</p><Button onClick={collection.clearSelection}>Clear selection</Button>
|
|
32
|
+
</section>;
|
|
33
|
+
}
|
|
34
|
+
export function ConsequentialActionExample({ remove, restore }: { remove: () => Promise<{ ok: true; value: string } | { ok: false; error: string }>;
|
|
35
|
+
restore: (id: string) => Promise<{ ok: true } | { ok: false; error: string }> }) {
|
|
36
|
+
const fallback = React.useRef<HTMLHeadingElement>(null);
|
|
37
|
+
const action = useConsequentialAction({ target: 'Draft proposal', consequence: 'Move Draft proposal to trash. You can restore it here.',
|
|
38
|
+
requiresConfirmation: true, execute: remove, undo: restore, focusFallback: () => fallback.current });
|
|
39
|
+
const open = ['confirming', 'pending'].includes(action.phase) || (action.phase === 'failed' && !action.canUndo);
|
|
40
|
+
return <section><h2 ref={fallback} tabIndex={-1}>Manage proposal</h2>
|
|
41
|
+
{action.phase !== 'succeeded' && <Button onClick={action.request}>Delete Draft proposal</Button>}
|
|
42
|
+
<Dialog open={open} returnFocus={() => fallback.current} title={`Delete ${action.target}?`} description={action.consequence} dismissible={action.phase !== 'pending'}
|
|
43
|
+
onOpenChange={next => { if (!next) action.cancel(); }} footer={<>
|
|
44
|
+
<Button disabled={action.phase === 'pending'} onClick={action.cancel}>Keep proposal</Button>
|
|
45
|
+
<Button variant="danger" loading={action.phase === 'pending'} onClick={action.phase === 'failed' ? action.retry : action.confirm}>Delete proposal</Button>
|
|
46
|
+
</>}><p>{action.error || 'Review the target before continuing.'}</p></Dialog>
|
|
47
|
+
<WorkflowStatus>{action.status}</WorkflowStatus>
|
|
48
|
+
{action.canUndo && <Button onClick={action.undo}>Restore proposal</Button>}
|
|
49
|
+
{action.phase === 'failed' && action.canUndo && <Button onClick={action.retry}>Retry restoration</Button>}
|
|
50
|
+
</section>;
|
|
51
|
+
}
|
|
52
|
+
export function ReviewSubmitExample({ send }: { send: (draft: RecordValue) => Promise<{ ok: true; receipt: { reference: string } } | { ok: false; error: string }> }) {
|
|
53
|
+
const form = useReviewSubmit({ initialValue: { name: '' }, validate: value => value.name.trim() ? {} : { name: 'Enter a name.' }, submit: send });
|
|
54
|
+
const id = React.useId();
|
|
55
|
+
const heading = React.useRef<HTMLHeadingElement>(null);
|
|
56
|
+
const previousPhase = React.useRef(form.phase);
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
if (previousPhase.current !== form.phase) {
|
|
59
|
+
if (form.phase === 'editing') document.getElementById(id)?.focus();
|
|
60
|
+
else if (form.phase === 'review' || form.phase === 'complete') heading.current?.focus();
|
|
61
|
+
previousPhase.current = form.phase;
|
|
62
|
+
}
|
|
63
|
+
}, [form.phase, id]);
|
|
64
|
+
return <section aria-label="Review and send"><h2 ref={heading} tabIndex={-1}>{form.phase === 'editing' ? 'Your answers' : form.phase === 'complete' ? 'Request received' : 'Review your answers'}</h2><p>Step {form.phase === 'editing' ? '1 of 2: Your answers' : form.phase === 'complete' ? '2 of 2: Complete' : '2 of 2: Review your answers'}</p>
|
|
65
|
+
{form.phase === 'editing' ? <form noValidate onSubmit={event => { event.preventDefault(); form.review(); }}><ErrorSummary errors={form.errors.name ? [{ id, label: 'Your name', message: form.errors.name }] : []} focusKey={form.submitCount} />
|
|
66
|
+
<Field label="Your name" error={form.errors.name}><Input id={id} value={form.draft.name} onChange={event => form.setField('name', event.target.value)} /></Field>
|
|
67
|
+
<Button type="submit">Review answers</Button></form> : form.phase === 'complete' ? <p>Confirmation: {form.receipt?.reference}</p> : <>
|
|
68
|
+
<dl><dt>Your name</dt><dd>{form.draft.name}</dd></dl>
|
|
69
|
+
<Button disabled={form.phase === 'submitting'} onClick={form.correct}>Correct your answers</Button><Button loading={form.phase === 'submitting'} onClick={form.submit}>Send request</Button>
|
|
70
|
+
</>}<WorkflowStatus>{form.status}</WorkflowStatus></section>;
|
|
71
|
+
}
|
|
72
|
+
export function PatternWorkflows() {
|
|
73
|
+
const data = React.useRef({ record: { name: 'Northwind' }, proposal: true, submissions: [] as RecordValue[] });
|
|
74
|
+
const fail = React.useRef(false);
|
|
75
|
+
const delay = () => new Promise<void>((resolve, reject) => setTimeout(() => { if (fail.current) { fail.current = false; reject(Error('Controlled failure')); } else resolve(); }, 350));
|
|
76
|
+
return <main><h1>Workflow examples</h1><p>This demo stores records in memory; reloading resets its data.</p>
|
|
77
|
+
<Button onClick={() => { fail.current = true; }}>Fail the next operation</Button>
|
|
78
|
+
<EditRecordExample initialValue={data.current.record} save={async value => { await delay(); data.current.record = { name: value.name.trim() }; return { ok: true, value: data.current.record }; }} />
|
|
79
|
+
<AsyncCollectionExample load={async query => { await delay(); return [{id:'workspace',name:data.current.record.name}].filter(item => item.name.toLowerCase().includes(query.toLowerCase())); }} />
|
|
80
|
+
<ConsequentialActionExample remove={async () => { await delay(); data.current.proposal = false; return {ok:true,value:'proposal'}; }} restore={async () => { await delay(); data.current.proposal = true; return {ok:true}; }} />
|
|
81
|
+
<ReviewSubmitExample send={async value => { await delay(); data.current.submissions.push(value); return {ok:true,receipt:{reference:`Request ${data.current.submissions.length}`}}; }} />
|
|
82
|
+
</main>;
|
|
83
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Agent action pattern (project/framework/patterns/agent-action.md): a person asks, the agent shows its steps, and each
|
|
2
|
+
// step's state maps to one reading: planned is an imprint, running is raised, awaiting approval is Proposed (raised, not
|
|
3
|
+
// pending), approved and executing hovers as pending, done lands, failed sinks with Retry, rejected lies flush. A
|
|
4
|
+
// reversible step runs without asking and offers Undo; sending email asks; deleting for good asks in a stationary
|
|
5
|
+
// dialog. Everything the agent changed carries provenance.
|
|
6
|
+
// The lines between the preview markers are its "AgentActionPattern" preview (scripts/pattern-previews.mjs copies them);
|
|
7
|
+
// build/checks/pattern-types.mjs type-checks this file against the public declarations and fails if the two differ.
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
import * as A from 'altitude-design';
|
|
10
|
+
// preview:start
|
|
11
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
|
+
function App() {
|
|
13
|
+
const [failNext, setFailNext] = React.useState(false);
|
|
14
|
+
const fail = React.useRef(false); fail.current = failNext;
|
|
15
|
+
const [search, setSearch] = React.useState(/** @type {'running' | 'done' | 'failed'} */ ('running'));
|
|
16
|
+
const [archive, setArchive] = React.useState(/** @type {'planned' | 'running' | 'done'} */ ('planned'));
|
|
17
|
+
const [undone, setUndone] = React.useState(false);
|
|
18
|
+
const [email, setEmail] = React.useState(/** @type {A.ConfirmationState} */ ('requested'));
|
|
19
|
+
const [purge, setPurge] = React.useState(/** @type {A.ConfirmationState} */ ('requested'));
|
|
20
|
+
const runSearch = async () => { setSearch('running'); await wait(900); if (fail.current) { setFailNext(false); setSearch('failed'); return; } setSearch('done'); setArchive('running'); await wait(800); setArchive('done'); };
|
|
21
|
+
const restart = () => { setArchive('planned'); setUndone(false); setEmail('requested'); setPurge('requested'); void runSearch(); };
|
|
22
|
+
React.useEffect(() => { void runSearch(); }, []);
|
|
23
|
+
const resolve = async (set, ok) => { set('running'); await wait(900); set(ok ? 'done' : 'failed'); };
|
|
24
|
+
const stepFor = (c) => (c === 'requested' ? 'awaiting-approval' : c === 'running' || c === 'approved' ? 'running' : c === 'failed' ? 'failed' : 'done');
|
|
25
|
+
const ready = archive === 'done';
|
|
26
|
+
return (
|
|
27
|
+
<main className="ag-page">
|
|
28
|
+
<div className="ag-ask"><span className="ag-who">You asked</span><p>Clean up leads nobody has touched since July, and tell their owners.</p></div>
|
|
29
|
+
<div className="ag-demo"><A.Switch label="Make the next step fail" checked={failNext} onCheckedChange={setFailNext} /><A.Button size="sm" onClick={restart}>Run again</A.Button></div>
|
|
30
|
+
<A.Trace aria-label="Assistant run: clean up stale leads">
|
|
31
|
+
<A.TraceStep title="Find leads untouched since July" status={search} duration={search === 'done' ? 900 : undefined}
|
|
32
|
+
error={search === 'failed' ? 'The CRM didn’t answer in time. Nothing was changed.' : undefined} onRetry={search === 'failed' ? runSearch : undefined}>
|
|
33
|
+
<A.TraceToolCall name="search_leads" input={{ untouchedSince: '2026-07-01', limit: 50 }} output={search === 'done' ? { found: 4 } : undefined} />
|
|
34
|
+
</A.TraceStep>
|
|
35
|
+
<A.TraceStep title="Archive 4 stale leads" status={archive}>
|
|
36
|
+
{archive === 'done' && <div className="ag-receipt"><span role="status">{undone ? 'Archive undone. The 4 leads are back.' : '4 leads archived. You can undo this for 30 days.'}</span>
|
|
37
|
+
{!undone && <A.Button size="sm" variant="ghost" onClick={() => setUndone(true)}>Undo archive</A.Button>}</div>}
|
|
38
|
+
</A.TraceStep>
|
|
39
|
+
<A.TraceStep title={email === 'rejected' ? 'Didn’t email the lead owners' : 'Email 3 lead owners'} status={ready ? stepFor(email) : 'planned'}>
|
|
40
|
+
{ready && <A.Confirmation state={email} title="Email 3 lead owners" requestedBy="The assistant" resolvedBy="Maya Chen"
|
|
41
|
+
consequence="Theo Marsh, Ines Duarte and Sam Okafor will get an email listing the leads that were archived."
|
|
42
|
+
approveLabel="Send 3 emails" rejectLabel="Don’t send" runningLabel="Sending…" doneLabel="3 emails sent" failedLabel="The emails weren’t sent."
|
|
43
|
+
onApprove={() => resolve(setEmail, !fail.current)} onReject={() => setEmail('rejected')} onRetry={() => resolve(setEmail, true)} />}
|
|
44
|
+
</A.TraceStep>
|
|
45
|
+
<A.TraceStep title="Delete the “Summer import” list for good" status={ready && email !== 'requested' ? stepFor(purge) : 'planned'}>
|
|
46
|
+
{ready && email !== 'requested' && <A.Confirmation state={purge} escalate="dialog" title="Delete the “Summer import” list" resolvedBy="Maya Chen"
|
|
47
|
+
consequence="The list and its 212 rows are deleted permanently. Leads in it stay; only the list goes."
|
|
48
|
+
approveLabel="Delete list" rejectLabel="Keep the list" runningLabel="Deleting…" doneLabel="List deleted"
|
|
49
|
+
onApprove={() => resolve(setPurge, true)} onReject={() => setPurge('rejected')} />}
|
|
50
|
+
</A.TraceStep>
|
|
51
|
+
</A.Trace>
|
|
52
|
+
{email === 'done' && <section className="ag-record" aria-labelledby="ag-rec-h"><h3 id="ag-rec-h" className="ag-sub">Northwind Traders</h3>
|
|
53
|
+
<p className="ag-line">Status: Archived <A.AILabel subject="Change" model="Opus 5.5" reviewedBy="Maya Chen" generatedAt="2026-10-30T09:00:00Z" /></p>
|
|
54
|
+
<p className="ag-muted">Archived by the assistant, approved by Maya Chen.</p>
|
|
55
|
+
</section>}
|
|
56
|
+
</main>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
// preview:end
|
|
60
|
+
export default App;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// App skeleton: a starting point for a SaaS app, with the parts composed the way Altitude intends. The shell is on the
|
|
2
|
+
// ground; each view has one primary action in its PageHeader; a list page filters, pages and selects in a DataTable; a
|
|
3
|
+
// record opens in a Sheet with a PropertyList; settings are rows saved with the SaveBar. Copy it and replace the data.
|
|
4
|
+
// The lines between the preview markers are the "AppSkeleton" preview (scripts/pattern-previews.mjs copies them);
|
|
5
|
+
// build/checks/pattern-types.mjs type-checks this file against the public declarations and fails if the two differ.
|
|
6
|
+
// preview-name: AppSkeleton
|
|
7
|
+
import * as React from 'react';
|
|
8
|
+
import * as A from 'altitude-design';
|
|
9
|
+
// preview:start
|
|
10
|
+
const I = ({ d }) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>;
|
|
11
|
+
const ICONS = { customers: 'M16 19v-1a4 4 0 0 0-8 0v1M12 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6', reports: 'M5 20V10M12 20V4M19 20v-7', settings: 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4' };
|
|
12
|
+
const PLANS = [{ value: 'Free', label: 'Free' }, { value: 'Pro', label: 'Pro' }, { value: 'Business', label: 'Business' }];
|
|
13
|
+
const PEOPLE = ['Ada Lovelace', 'Grace Hopper', 'Alan Turing', 'Katherine Johnson', 'Hedy Lamarr', 'Edsger Dijkstra', 'Barbara Liskov', 'Donald Knuth', 'Margaret Hamilton', 'Ken Thompson', 'Radia Perlman', 'Tim Berners-Lee'];
|
|
14
|
+
const SEED = PEOPLE.map((name, i) => ({ id: 'c' + i, name, email: name.split(' ')[0].toLowerCase() + '@example.com', plan: PLANS[i % 3].value, seats: 3 + ((i * 7) % 40) }));
|
|
15
|
+
const FIELDS = /** @type {A.FilterField[]} */ ([{ key: 'plan', label: 'Plan', type: 'enum', options: PLANS }, { key: 'name', label: 'Name', type: 'text' }]);
|
|
16
|
+
const matches = (row, f) => f.value === undefined || f.value === '' || (f.operator === 'is' ? row[f.field] === f.value : f.operator === 'is not' ? row[f.field] !== f.value
|
|
17
|
+
: f.operator === 'contains' ? String(row[f.field]).toLowerCase().includes(String(f.value).toLowerCase()) : !String(row[f.field]).toLowerCase().includes(String(f.value).toLowerCase()));
|
|
18
|
+
function Customers() {
|
|
19
|
+
const [rows, setRows] = React.useState(SEED);
|
|
20
|
+
const [filters, setFilters] = React.useState(/** @type {A.Filter[]} */ ([]));
|
|
21
|
+
const [page, setPage] = React.useState(1);
|
|
22
|
+
const [selected, setSelected] = React.useState(/** @type {string[]} */ ([]));
|
|
23
|
+
const [open, setOpen] = React.useState(/** @type {string | null} */ (null));
|
|
24
|
+
const shown = rows.filter((r) => filters.every((f) => matches(r, f)));
|
|
25
|
+
const pageCount = Math.max(1, Math.ceil(shown.length / 5));
|
|
26
|
+
const record = rows.find((r) => r.id === open);
|
|
27
|
+
const save = (key) => async (value) => { await new Promise((r) => setTimeout(r, 400)); setRows((l) => l.map((r) => (r.id === open ? { ...r, [key]: value } : r))); return /** @type {const} */ ({ ok: true, value }); };
|
|
28
|
+
return <>
|
|
29
|
+
<A.PageHeader primary={<A.Button variant="primary">Add customer</A.Button>} actions={<A.Button variant="secondary">Export</A.Button>}>
|
|
30
|
+
<A.PageHeaderTitle>Customers</A.PageHeaderTitle><A.PageHeaderDescription>{rows.length} customers on every plan.</A.PageHeaderDescription>
|
|
31
|
+
</A.PageHeader>
|
|
32
|
+
<div className="sk-body">
|
|
33
|
+
<A.FilterBar aria-label="Filter customers" fields={FIELDS} value={filters} onValueChange={(f) => { setFilters(f); setPage(1); }} resultCount={shown.length} />
|
|
34
|
+
<A.DataTable aria-label="Customers" noun="customers" rows={shown.slice((page - 1) * 5, page * 5)} getRowId={(r) => r.id} getRowLabel={(r) => r.name}
|
|
35
|
+
selectable selected={selected} onSelectedChange={setSelected} matchingTotal={shown.length}
|
|
36
|
+
actions={(ids) => <A.Button size="sm" variant="secondary" onClick={() => setSelected([])}>Email {ids.length}</A.Button>}
|
|
37
|
+
onRowClick={(r) => setOpen(r.id)} empty="No customers match these filters."
|
|
38
|
+
columns={[{ key: 'name', header: 'Name' }, { key: 'email', header: 'Email' }, { key: 'plan', header: 'Plan', cell: (r) => <A.Badge>{r.plan}</A.Badge> }, { key: 'seats', header: 'Seats', align: 'end' }]}
|
|
39
|
+
pagination={{ page, pageCount, total: shown.length, onPageChange: setPage, noun: 'customers' }} />
|
|
40
|
+
</div>
|
|
41
|
+
<A.Sheet open={!!record} onOpenChange={(o) => { if (!o) setOpen(null); }} size="md">
|
|
42
|
+
{record && <>
|
|
43
|
+
<A.SheetHeader><A.SheetTitle>{record.name}</A.SheetTitle><A.SheetDescription>{record.email}</A.SheetDescription></A.SheetHeader>
|
|
44
|
+
<A.PropertyList aria-label={`${record.name}’s details`}>
|
|
45
|
+
<A.PropertyListItem label="Plan" value={record.plan} commit="on-choose" onSave={save('plan')} editor={(c) => <A.Select {...c} items={PLANS} />} />
|
|
46
|
+
<A.PropertyListItem label="Seats" value={String(record.seats)} onSave={save('seats')} validate={(v) => (/^\d+$/.test(v) ? undefined : 'Enter a whole number.')} />
|
|
47
|
+
<A.PropertyListItem label="Email" value={record.email} />
|
|
48
|
+
</A.PropertyList>
|
|
49
|
+
</>}
|
|
50
|
+
</A.Sheet>
|
|
51
|
+
</>;
|
|
52
|
+
}
|
|
53
|
+
function Settings() {
|
|
54
|
+
const general = A.useEditRecord({ initialValue: { name: 'Acme', url: 'acme' }, validationMode: 'submit',
|
|
55
|
+
validate: (d) => (d.name.trim() ? {} : { name: 'Enter a workspace name.' }), save: async (d) => { await new Promise((r) => setTimeout(r, 600)); return { ok: true, value: d }; } });
|
|
56
|
+
const [digest, setDigest] = React.useState(true);
|
|
57
|
+
return <>
|
|
58
|
+
<A.PageHeader><A.PageHeaderTitle>Settings</A.PageHeaderTitle><A.PageHeaderDescription>Changes to the workspace are saved together.</A.PageHeaderDescription></A.PageHeader>
|
|
59
|
+
<div className="sk-body">
|
|
60
|
+
<A.SettingsSection title="Workspace">
|
|
61
|
+
<A.SettingsRow label="Workspace name" error={general.errors.name}><A.Input value={general.draft.name} onChange={(e) => general.setField('name', e.target.value)} /></A.SettingsRow>
|
|
62
|
+
<A.SettingsRow label="Workspace URL" description={'altitude.app/' + general.draft.url}><A.Input value={general.draft.url} onChange={(e) => general.setField('url', e.target.value)} /></A.SettingsRow>
|
|
63
|
+
</A.SettingsSection>
|
|
64
|
+
<A.SettingsSection title="Notifications" description="Applies as soon as you change it.">
|
|
65
|
+
<A.SettingsRow label="Weekly digest" description="A summary every Monday."><A.Switch checked={digest} onCheckedChange={setDigest} /></A.SettingsRow>
|
|
66
|
+
</A.SettingsSection>
|
|
67
|
+
</div>
|
|
68
|
+
<A.SaveBar record={general} />
|
|
69
|
+
</>;
|
|
70
|
+
}
|
|
71
|
+
function App() {
|
|
72
|
+
const [at, setAt] = React.useState('customers');
|
|
73
|
+
const item = (key, label) => <A.SidebarMenuItem key={key}>
|
|
74
|
+
<A.SidebarMenuButton href={'#/' + key} current={at === key} icon={<I d={ICONS[key]} />} onClick={(e) => { e.preventDefault(); setAt(key); }}>{label}</A.SidebarMenuButton>
|
|
75
|
+
</A.SidebarMenuItem>;
|
|
76
|
+
return (
|
|
77
|
+
<div className="sk-frame">
|
|
78
|
+
<A.AppShell
|
|
79
|
+
sidebar={<A.Sidebar>
|
|
80
|
+
<A.SidebarHeader><span className="sk-ws"><A.Avatar name="Acme" size={24} /><span className="alt-sidebar-text">Acme</span></span></A.SidebarHeader>
|
|
81
|
+
<A.SidebarContent><A.SidebarGroup label="Workspace"><A.SidebarMenu>{item('customers', 'Customers')}{item('reports', 'Reports')}{item('settings', 'Settings')}</A.SidebarMenu></A.SidebarGroup></A.SidebarContent>
|
|
82
|
+
<A.SidebarFooter><A.SidebarMenu><A.SidebarMenuItem><A.SidebarMenuButton href="#/me" icon={<A.Avatar name="Maya Chen" size={20} />}>Maya Chen</A.SidebarMenuButton></A.SidebarMenuItem></A.SidebarMenu></A.SidebarFooter>
|
|
83
|
+
</A.Sidebar>}
|
|
84
|
+
topBar={<A.Button variant="secondary" size="sm" className="sk-search">Search… <A.Kbd>⌘K</A.Kbd></A.Button>}>
|
|
85
|
+
{at === 'customers' ? <Customers /> : at === 'settings' ? <Settings /> : <>
|
|
86
|
+
<A.PageHeader><A.PageHeaderTitle>Reports</A.PageHeaderTitle></A.PageHeader>
|
|
87
|
+
<div className="sk-body"><A.EmptyState title="No reports yet" description="Reports you save appear here." action={<A.Button variant="primary">Create a report</A.Button>} /></div>
|
|
88
|
+
</>}
|
|
89
|
+
</A.AppShell>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
// preview:end
|
|
94
|
+
export default App;
|