canon-ds 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/THIRD_PARTY_NOTICES.md +55 -0
  4. package/bin/canon.js +6 -0
  5. package/lib/build-manifest.mjs +54 -0
  6. package/lib/build.mjs +69 -0
  7. package/lib/cli.mjs +273 -0
  8. package/lib/color.js +160 -0
  9. package/lib/components/_shared.mjs +93 -0
  10. package/lib/components/accordion.mjs +136 -0
  11. package/lib/components/account-card.mjs +166 -0
  12. package/lib/components/activity-feed.mjs +176 -0
  13. package/lib/components/activity-gauge.mjs +98 -0
  14. package/lib/components/agent-presence.mjs +156 -0
  15. package/lib/components/alert.mjs +154 -0
  16. package/lib/components/app-store-button.mjs +108 -0
  17. package/lib/components/avatar-group.mjs +128 -0
  18. package/lib/components/avatar.mjs +225 -0
  19. package/lib/components/badge-group.mjs +55 -0
  20. package/lib/components/badge.mjs +174 -0
  21. package/lib/components/banner.mjs +116 -0
  22. package/lib/components/breadcrumb.mjs +170 -0
  23. package/lib/components/button-group.mjs +114 -0
  24. package/lib/components/button.mjs +197 -0
  25. package/lib/components/card-header.mjs +115 -0
  26. package/lib/components/card.mjs +106 -0
  27. package/lib/components/carousel.mjs +182 -0
  28. package/lib/components/chart-frame.mjs +101 -0
  29. package/lib/components/checkbox.mjs +163 -0
  30. package/lib/components/close-button.mjs +49 -0
  31. package/lib/components/code.mjs +123 -0
  32. package/lib/components/color-picker.mjs +122 -0
  33. package/lib/components/combobox.mjs +210 -0
  34. package/lib/components/command-palette.mjs +226 -0
  35. package/lib/components/content-divider.mjs +104 -0
  36. package/lib/components/counter.mjs +86 -0
  37. package/lib/components/credit-card.mjs +126 -0
  38. package/lib/components/date-picker.mjs +202 -0
  39. package/lib/components/description-list.mjs +88 -0
  40. package/lib/components/dialog.mjs +140 -0
  41. package/lib/components/divider.mjs +115 -0
  42. package/lib/components/drawer.mjs +150 -0
  43. package/lib/components/empty-state.mjs +112 -0
  44. package/lib/components/featured-card.mjs +124 -0
  45. package/lib/components/featured-icon.mjs +67 -0
  46. package/lib/components/field.mjs +122 -0
  47. package/lib/components/file-dropzone.mjs +166 -0
  48. package/lib/components/filter-bar.mjs +179 -0
  49. package/lib/components/header-navigation.mjs +94 -0
  50. package/lib/components/icon-button.mjs +179 -0
  51. package/lib/components/index.mjs +52 -0
  52. package/lib/components/inline-cta.mjs +139 -0
  53. package/lib/components/input-group.mjs +164 -0
  54. package/lib/components/input.mjs +197 -0
  55. package/lib/components/kanban.mjs +179 -0
  56. package/lib/components/kbd.mjs +70 -0
  57. package/lib/components/kicker.mjs +79 -0
  58. package/lib/components/link.mjs +108 -0
  59. package/lib/components/list.mjs +117 -0
  60. package/lib/components/media-frame.mjs +119 -0
  61. package/lib/components/menu.mjs +190 -0
  62. package/lib/components/message.mjs +249 -0
  63. package/lib/components/metric-group.mjs +210 -0
  64. package/lib/components/mobile-header.mjs +173 -0
  65. package/lib/components/multi-select.mjs +218 -0
  66. package/lib/components/notification.mjs +175 -0
  67. package/lib/components/number-input.mjs +149 -0
  68. package/lib/components/page-header.mjs +68 -0
  69. package/lib/components/pagination.mjs +236 -0
  70. package/lib/components/pin-input.mjs +120 -0
  71. package/lib/components/popover.mjs +126 -0
  72. package/lib/components/progress-circle.mjs +115 -0
  73. package/lib/components/progress.mjs +93 -0
  74. package/lib/components/prose.mjs +96 -0
  75. package/lib/components/radio.mjs +160 -0
  76. package/lib/components/rating.mjs +93 -0
  77. package/lib/components/rich-text-editor.mjs +178 -0
  78. package/lib/components/section-header.mjs +62 -0
  79. package/lib/components/segmented-control.mjs +121 -0
  80. package/lib/components/select.mjs +163 -0
  81. package/lib/components/sidebar-nav.mjs +172 -0
  82. package/lib/components/sidebar.mjs +124 -0
  83. package/lib/components/skeleton.mjs +66 -0
  84. package/lib/components/slider.mjs +122 -0
  85. package/lib/components/social-button.mjs +119 -0
  86. package/lib/components/spinner.mjs +69 -0
  87. package/lib/components/stat.mjs +120 -0
  88. package/lib/components/stepper.mjs +228 -0
  89. package/lib/components/switch.mjs +159 -0
  90. package/lib/components/table.mjs +212 -0
  91. package/lib/components/tabs.mjs +207 -0
  92. package/lib/components/tag.mjs +196 -0
  93. package/lib/components/tags-input.mjs +146 -0
  94. package/lib/components/textarea.mjs +117 -0
  95. package/lib/components/timeline.mjs +135 -0
  96. package/lib/components/toast.mjs +115 -0
  97. package/lib/components/tooltip.mjs +86 -0
  98. package/lib/components/topbar.mjs +207 -0
  99. package/lib/components/tree-view.mjs +156 -0
  100. package/lib/components/video-player.mjs +189 -0
  101. package/lib/connect.mjs +110 -0
  102. package/lib/design-files.mjs +175 -0
  103. package/lib/distribution.mjs +109 -0
  104. package/lib/editor.js +940 -0
  105. package/lib/engine.js +339 -0
  106. package/lib/generators/agents.mjs +68 -0
  107. package/lib/generators/connection.md +58 -0
  108. package/lib/generators/css.mjs +2 -0
  109. package/lib/generators/designmd.mjs +324 -0
  110. package/lib/generators/documentation.css +82 -0
  111. package/lib/generators/documentation.html +81 -0
  112. package/lib/generators/documentation.js +96 -0
  113. package/lib/generators/documentation.mjs +35 -0
  114. package/lib/generators/dtcg.mjs +60 -0
  115. package/lib/generators/preview.css +279 -0
  116. package/lib/generators/preview.mjs +225 -0
  117. package/lib/generators/react.mjs +162 -0
  118. package/lib/generators/tailwind.mjs +114 -0
  119. package/lib/install.mjs +108 -0
  120. package/lib/lint.mjs +538 -0
  121. package/lib/mcp.mjs +268 -0
  122. package/lib/open.mjs +7 -0
  123. package/lib/patterns/_app.mjs +56 -0
  124. package/lib/patterns/app-shell.mjs +25 -0
  125. package/lib/patterns/audit-log.mjs +37 -0
  126. package/lib/patterns/auth-pages.mjs +44 -0
  127. package/lib/patterns/billing-page.mjs +39 -0
  128. package/lib/patterns/calendar-page.mjs +55 -0
  129. package/lib/patterns/dashboard-page.mjs +28 -0
  130. package/lib/patterns/files-page.mjs +30 -0
  131. package/lib/patterns/first-run.mjs +31 -0
  132. package/lib/patterns/form-layout.mjs +24 -0
  133. package/lib/patterns/inbox-page.mjs +58 -0
  134. package/lib/patterns/index.mjs +29 -0
  135. package/lib/patterns/list-detail-page.mjs +24 -0
  136. package/lib/patterns/mobile-app.mjs +46 -0
  137. package/lib/patterns/modal-flows.mjs +32 -0
  138. package/lib/patterns/onboarding-page.mjs +42 -0
  139. package/lib/patterns/permissions-page.mjs +38 -0
  140. package/lib/patterns/profile-page.mjs +32 -0
  141. package/lib/patterns/settings-page.mjs +24 -0
  142. package/lib/project.mjs +28 -0
  143. package/lib/serve.mjs +279 -0
  144. package/lib/system.mjs +210 -0
  145. package/lib/tokens/base.js +251 -0
  146. package/lib/tokens/canon-preset.mjs +67 -0
  147. package/lib/tokens/index.mjs +1 -0
  148. package/lib/tokens/legacy-canon.json +559 -0
  149. package/lib/tokens/presets.mjs +161 -0
  150. package/lib/tokens/resolve.mjs +1 -0
  151. package/lib/types.mjs +4 -0
  152. package/lib/version.mjs +2 -0
  153. package/package.json +54 -0
@@ -0,0 +1,42 @@
1
+ import { BTN, FIELD, INPUT, ICON, APP_CSS, AVATAR } from "./_app.mjs";
2
+ const STEPS = (current) => `<ol class="cn-stepper" data-variant="number" aria-label="Setup progress" data-orientation="horizontal" data-size="md">${['Your details', 'Company', 'Invite team', 'Done'].map((s, i) => `<li class="cn-stepper__step" data-state="${i < current ? 'complete' : i === current ? 'current' : 'upcoming'}"${i === current ? ' aria-current="step"' : ''}><span class="cn-stepper__indicator">${i < current ? ICON.check.replace('cn-icon', 'cn-stepper__check') : i + 1}</span><span class="cn-stepper__content"><span class="cn-stepper__label">${s}</span></span>${i < 3 ? '<span class="cn-stepper__connector" aria-hidden="true"></span>' : ''}</li>`).join('')}</ol>`;
3
+ const LOGO = `<span class="cn-onb__logo" aria-hidden="true"></span>`;
4
+ const CARD = (title, text, body, step) => `<div class="cn-onb"><header class="cn-onb__head">${LOGO}<span class="cn-text-label-md">Lumen</span><span class="cn-text-body-sm cn-app__muted">Step ${step + 1} of 4</span></header><main class="cn-onb__main"><div class="cn-onb__card"><div class="cn-stack" data-gap="8">${STEPS(step)}<div class="cn-stack" data-gap="2"><h1 class="cn-text-heading-lg">${title}</h1><p class="cn-text-body-lg cn-app__muted">${text}</p></div>${body}</div></div></main></div>`;
5
+ export const onboardingPage = {
6
+ name: 'Onboarding wizard',
7
+ slug: 'onboarding-page',
8
+ category: 'app-page',
9
+ description: 'First-run flow: a focused, single-column card with a step indicator, one question per step, a back/continue footer, and a finishing screen. Also as a split layout with a preview panel.',
10
+ rules: [
11
+ 'No sidebar. A minimal header (logo, product name, "Step n of 4") and a centered 560px card with 32px padding.',
12
+ 'The stepper at the top of the card shows 3–5 steps: complete (check), current (brand), upcoming (muted); never more than 5.',
13
+ 'One question per step: heading-lg title, body-lg supporting text, then 1–4 fields (20px apart). Continue is the only primary button; Back is ghost on the left; "Skip for now" as a link when optional.',
14
+ 'Invite step: repeatable email rows with a role select and an "Add another" link button; the primary reads "Send invites".',
15
+ 'Finishing screen: success featured icon (outline circle), heading-lg, body-lg, one primary "Go to dashboard".',
16
+ ],
17
+ html: CARD('Tell us about you', 'This helps us personalize your workspace.', `<form class="cn-stack" data-gap="5">${FIELD('o-name', 'Full name', INPUT('o-name', 'Maya Chen'))}${FIELD('o-role', 'What best describes your role?', `<div class="cn-select" data-variant="default" data-size="md"><select class="cn-select__field" id="o-role" aria-label="Role"><option>Founder</option><option selected>Product</option><option>Design</option><option>Engineering</option></select><svg class="cn-select__chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></div>`)}${FIELD('o-team', 'Team size', `<div class="cn-segmented-control" data-size="md" data-variant="full" role="radiogroup" aria-label="Team size">${['Just me', '2–10', '11–50', '51+'].map((v, i) => `<button type="button" class="cn-segmented-control__item" role="radio" aria-checked="${i === 1}">${v}</button>`).join('')}</div>`)}<footer class="cn-onb__foot">${BTN('ghost', 'Back', 'lg')}${BTN('primary', 'Continue', 'lg')}</footer></form>`, 0),
18
+ variants: [
19
+ { title: 'Company step with logo upload', html: CARD('Set up your company', 'You can change these later in Settings.', `<form class="cn-stack" data-gap="5">${FIELD('o-company', 'Company name', INPUT('o-company', 'Lumen'))}${FIELD('o-url', 'Workspace URL', `<div class="cn-input" data-variant="default" data-size="md"><span class="cn-input__affix">app.lumen.co/</span><input class="cn-input__field" id="o-url" type="text" value="lumen"></div>`)}${FIELD('o-logo', 'Logo', `<div class="cn-row" data-gap="4"><span class="cn-avatar" data-size="xl" data-shape="square" data-tone="accent" data-status="none" role="img" aria-label="Logo preview"><span class="cn-avatar__fallback" aria-hidden="true">L</span></span><div class="cn-file-dropzone cn-flex-1" data-size="sm"><input class="cn-file-dropzone__input" type="file" aria-label="Upload logo"><span class="cn-file-dropzone__icon" aria-hidden="true">${ICON.copy}</span><span class="cn-file-dropzone__title"><strong>Click to upload</strong> or drag and drop</span><span class="cn-file-dropzone__hint">SVG or PNG, at least 256×256</span></div></div>`)}<footer class="cn-onb__foot">${BTN('ghost', 'Back', 'lg')}${BTN('primary', 'Continue', 'lg')}</footer></form>`, 1) },
20
+ { title: 'Invite team', html: CARD('Invite your team', 'Add the people you work with. They’ll get an email with a link.', `<form class="cn-stack" data-gap="4">${['daniel@lumen.co', 'sofia@lumen.co', ''].map((e, i) => `<div class="cn-onb__invite"><div class="cn-input cn-flex-1" data-variant="default" data-size="md">${ICON.inbox.replace('cn-icon', 'cn-input__icon')}<input class="cn-input__field" type="email" value="${e}" placeholder="name@company.com" aria-label="Email ${i + 1}"></div><div class="cn-select" data-variant="default" data-size="md"><select class="cn-select__field" aria-label="Role ${i + 1}"><option>Member</option><option${i === 0 ? ' selected' : ''}>Admin</option></select><svg class="cn-select__chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></div></div>`).join('')}${BTN('link-color', '+ Add another', 'md')}<footer class="cn-onb__foot">${BTN('ghost', 'Back', 'lg')}<div class="cn-row" data-gap="3" data-wrap>${BTN('link', 'Skip for now', 'lg')}${BTN('primary', 'Send invites', 'lg')}</div></footer></form>`, 2) },
21
+ { title: 'Done', html: `<div class="cn-onb"><header class="cn-onb__head">${LOGO}<span class="cn-text-label-md">Lumen</span><span class="cn-text-body-sm cn-app__muted">Step 4 of 4</span></header><main class="cn-onb__main"><div class="cn-onb__card cn-center"><div class="cn-stack cn-center" data-gap="6" data-align="center"><span class="cn-featured-icon" data-theme="outline" data-tone="success" data-size="lg" data-shape="circle">${ICON.check.replace('cn-icon', 'cn-featured-icon__icon')}</span><div class="cn-stack" data-gap="2"><h1 class="cn-text-heading-lg">You’re all set</h1><p class="cn-text-body-lg cn-app__muted">Your workspace is ready and 2 invites are on their way. You can add more people any time from Settings → Team.</p></div><div class="cn-row" data-gap="3" data-justify="center">${AVATAR('MC', 'Maya Chen', 'md', 'accent')}${AVATAR('DC', 'Daniel Costa', 'md', 'success')}${AVATAR('SA', 'Sofia Almeida', 'md', 'warning')}</div>${BTN('primary', 'Go to dashboard', 'lg')}</div></div></main></div>` },
22
+ { title: 'Split with live preview', html: `<div class="cn-onb cn-onb--split"><div class="cn-onb__side"><header class="cn-onb__head">${LOGO}<span class="cn-text-label-md">Lumen</span></header><main class="cn-onb__main"><div class="cn-onb__card cn-onb__card--flush"><div class="cn-stack" data-gap="8">${STEPS(1)}<div class="cn-stack" data-gap="2"><h1 class="cn-text-heading-lg">Choose your brand color</h1><p class="cn-text-body-lg cn-app__muted">It’s used for buttons, links and highlights across the product.</p></div><div class="cn-row" data-gap="3" data-wrap>${['brand.600', 'blue.600', 'green.600', 'amber.600', 'red.600', 'neutral.900'].map((c, i) => `<button type="button" class="cn-onb__swatch" style="background:var(--cn-${c.replace('.', '-')})" aria-label="${c}" aria-pressed="${i === 0}"></button>`).join('')}</div><footer class="cn-onb__foot">${BTN('ghost', 'Back', 'lg')}${BTN('primary', 'Continue', 'lg')}</footer></div></div></main></div><aside class="cn-onb__preview" aria-label="Preview"><div class="cn-card" data-variant="default" data-padding="md" style="width:360px"><div class="cn-card__body cn-stack" data-gap="4"><div class="cn-row" data-gap="3">${AVATAR('MC', 'Maya Chen', 'md', 'accent')}<div class="cn-stack" data-gap="0"><span class="cn-text-label-sm">Maya Chen</span><span class="cn-text-body-sm cn-app__muted">Product Manager</span></div></div><p class="cn-text-body-md cn-app__muted">Your primary button, links and selected states will use the color you pick.</p><div class="cn-row" data-gap="2">${BTN('outline', 'Secondary', 'md')}${BTN('primary', 'Primary', 'md')}</div></div></div></aside></div>` },
23
+ ],
24
+ css: `${APP_CSS}
25
+ .cn-onb { min-height: 100vh; background-color: {color.bg-canvas}; display: flex; flex-direction: column; }
26
+ .cn-onb__head { display: flex; align-items: center; gap: {space.3}; height: 64px; padding-inline: {space.8}; }
27
+ .cn-onb__head > :last-child { margin-inline-start: auto; }
28
+ .cn-onb__logo { width: 32px; height: 32px; border-radius: {radius.lg}; background-color: {color.bg-action}; }
29
+ .cn-onb__main { flex: 1 1 auto; display: flex; align-items: flex-start; justify-content: center; padding: {space.10} {space.4} {space.16}; }
30
+ .cn-onb__card { width: 100%; max-width: 560px; background-color: {color.bg-surface}; border: {border.width.thin} solid {color.border-default}; border-radius: {radius.2xl}; padding: {space.8}; box-shadow: {shadow.xs}; }
31
+ .cn-onb__card--flush { border: 0; box-shadow: none; background: transparent; padding: 0; }
32
+ .cn-onb__foot { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: {space.3}; padding-top: {space.4}; border-top: {border.width.thin} solid {color.border-default}; margin-top: {space.2}; }
33
+ .cn-onb__invite { display: flex; gap: {space.3}; }
34
+ .cn-onb__invite > .cn-select { width: 140px; flex-shrink: 0; }
35
+ .cn-onb__swatch { width: 40px; height: 40px; border-radius: {radius.full}; border: 0; cursor: pointer; box-shadow: 0 0 0 2px {color.bg-surface}; }
36
+ .cn-onb__swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px {color.bg-surface}, 0 0 0 4px {color.border-action}; }
37
+ .cn-onb--split { flex-direction: row; }
38
+ .cn-onb__side { flex: 1 1 50%; display: flex; flex-direction: column; }
39
+ .cn-onb__preview { flex: 1 1 50%; background-color: {color.bg-subtle}; display: flex; align-items: center; justify-content: center; padding: {space.16}; border-inline-start: {border.width.thin} solid {color.border-default}; }
40
+ @media (max-width: {breakpoint.md}) { .cn-onb--split { flex-direction: column; } .cn-onb__preview { display: none; } .cn-onb__card { padding: {space.5}; } .cn-onb__head { padding-inline: {space.4}; } .cn-onb__invite { flex-direction: column; } .cn-onb__invite > .cn-input { flex: none; } .cn-onb__invite > .cn-select { width: 100%; } }`,
41
+ viewport: 'both',
42
+ };
@@ -0,0 +1,38 @@
1
+ import { SIDEBAR, PAGE_HEADER, BTN, AVATAR, BADGE, ICON, APP_CSS, TABS, CARD_HEAD, CHECKBOX } from "./_app.mjs";
2
+ const ROLES = ['Owner', 'Admin', 'Member', 'Viewer'];
3
+ const PERMS = [['Workspace', 'Manage billing', [true, true, false, false]], ['Workspace', 'Invite and remove members', [true, true, false, false]], ['Workspace', 'Change roles', [true, false, false, false]], ['Projects', 'Create projects', [true, true, true, false]], ['Projects', 'Delete projects', [true, true, false, false]], ['Projects', 'Edit any task', [true, true, true, false]], ['Data', 'Export data', [true, true, false, false]], ['Data', 'View audit log', [true, true, false, false]], ['Integrations', 'Manage API keys', [true, true, false, false]], ['Integrations', 'Connect apps', [true, true, true, false]]];
4
+ let last = '';
5
+ const MATRIX = `<table class="cn-table cn-perm__table" data-density="compact" data-variant="default"><thead class="cn-table__head"><tr class="cn-table__row"><th class="cn-table__header-cell" scope="col">Permission</th>${ROLES.map((r) => `<th class="cn-table__header-cell" scope="col" data-align="center"><div class="cn-stack cn-center" data-gap="1" data-align="center"><span>${r}</span><span class="cn-text-body-xs cn-app__muted">${[1, 3, 14, 6][ROLES.indexOf(r)]} people</span></div></th>`).join('')}</tr></thead><tbody class="cn-table__body">${PERMS.map(([group, p, vals]) => { const head = group !== last ? `<tr class="cn-table__row cn-perm__group"><td class="cn-table__cell" colspan="5">${group}</td></tr>` : ''; last = group; return head + `<tr class="cn-table__row"><td class="cn-table__cell"><span class="cn-text-body-sm">${p}</span></td>${vals.map((v, i) => `<td class="cn-table__cell" data-align="center">${i === 0 ? `<span class="cn-perm__lock" aria-label="Always on">${ICON.check}</span>` : CHECKBOX(`${p} for ${ROLES[i]}`, v)}</td>`).join('')}</tr>`; }).join('')}</tbody></table>`;
6
+ export const permissionsPage = {
7
+ name: 'Roles and permissions',
8
+ slug: 'permissions-page',
9
+ category: 'app-page',
10
+ description: 'A roles × permissions matrix with grouped rows, a role detail with its members, and an API keys page.',
11
+ rules: [
12
+ 'Matrix: permissions as rows grouped by area (group rows in bg-subtle, label-xs), roles as columns with the member count under the name; cells are checkboxes, the Owner column is locked (check icon, no control).',
13
+ 'Changes are saved explicitly: a sticky footer with "Discard" and "Save changes" appears when anything changed.',
14
+ 'Role detail: description, members list (avatar, name, email, remove) and an "Add members" primary; deleting a role requires moving its members first.',
15
+ 'API keys: table with name, key prefix in code style, created, last used, and a Revoke ghost-danger action; new keys are shown once in a dialog with a copy button.',
16
+ ],
17
+ html: `<div class="cn-app">${SIDEBAR('Settings')}<main class="cn-app__main">${PAGE_HEADER('Roles and permissions', 'Control what each role can do across the workspace.', BTN('outline', 'New role', 'md', ICON.plus))}${TABS(['Permissions', 'Roles', 'API keys'], 0)}<section class="cn-card cn-app__table-card" data-variant="default" data-padding="none"><div class="cn-section-header" data-variant="card" data-divider="yes">${CARD_HEAD('Permission matrix', 'Owners always have every permission.', BTN('link-color', 'Learn about roles', 'sm'))}</div><div class="cn-card__body cn-app__card-body cn-app__card-body--flush" role="region" tabindex="0" aria-label="Scrollable table">${MATRIX}</div><footer class="cn-card__footer"><div class="cn-row" data-justify="between"><span class="cn-text-body-sm cn-app__muted">2 unsaved changes</span><div class="cn-row" data-gap="2">${BTN('outline', 'Discard', 'sm')}${BTN('primary', 'Save changes', 'sm')}</div></div></footer></section></main></div>`,
18
+ variants: [
19
+ { title: 'Role detail with members', html: `<div class="cn-app">${SIDEBAR('Settings')}<main class="cn-app__main">${PAGE_HEADER('Admin', 'Can manage members, billing and projects. Cannot change roles.', `${BTN('danger-outline', 'Delete role', 'md')}${BTN('primary', 'Add members', 'md', ICON.plus)}`, 'simple', `<div class="cn-page-header__crumbs"><nav class="cn-breadcrumb" data-size="sm" aria-label="Breadcrumb"><ol class="cn-breadcrumb__list"><li class="cn-breadcrumb__item"><a class="cn-breadcrumb__link" href="#">Roles</a></li><li class="cn-breadcrumb__separator" aria-hidden="true">${ICON.chevronRight}</li><li class="cn-breadcrumb__item"><span class="cn-breadcrumb__current" aria-current="page">Admin</span></li></ol></nav></div>`)}<div class="cn-grid" data-cols="3" data-gap="6"><section class="cn-card cn-app__span2 cn-app__table-card" data-variant="default" data-padding="none"><div class="cn-section-header" data-variant="card" data-divider="yes">${CARD_HEAD('Members', '', '', BADGE('3 people', 'accent'))}</div><div class="cn-card__body cn-app__card-body cn-app__card-body--flush" role="region" tabindex="0" aria-label="Role members"><ul class="cn-list cn-perm__members" data-density="default" data-variant="plain">${[['MC', 'Maya Chen', 'maya@lumen.co', 'accent'], ['AK', 'Aisha Khan', 'aisha@lumen.co', 'accent'], ['ER', 'Elena Rossi', 'elena@lumen.co', 'warning']].map(([i, n, e, tone]) => `<li class="cn-list__item"><span class="cn-list__leading">${AVATAR(i, n, 'md', tone)}</span><span class="cn-list__content"><span class="cn-list__title">${n}</span><span class="cn-list__description">${e}</span></span><span class="cn-list__trailing">${BTN('ghost', 'Change role', 'sm')}${BTN('danger-ghost', 'Remove', 'sm')}</span></li>`).join('')}</ul></div></section><section class="cn-card" data-variant="default" data-padding="none"><div class="cn-section-header" data-variant="card" data-divider="yes">${CARD_HEAD('Permissions', '')}</div><div class="cn-card__body cn-app__card-body"><ul class="cn-stack" data-gap="3">${PERMS.map(([g, p, v]) => `<li class="cn-row" data-gap="2" data-align="start"><span class="cn-perm__mini" data-on="${v[1]}" aria-hidden="true">${v[1] ? ICON.check : ICON.x}</span><span class="cn-text-body-sm${v[1] ? '' : ' cn-app__muted'}">${p}</span></li>`).join('')}</ul></div></section></div></main></div>` },
20
+ { title: 'API keys', html: `<div class="cn-app">${SIDEBAR('Settings')}<main class="cn-app__main">${PAGE_HEADER('API keys', 'Keys give full access to your workspace’s API. Keep them secret.', BTN('primary', 'Create key', 'md', ICON.plus))}${TABS(['Permissions', 'Roles', 'API keys'], 2)}<div class="cn-alert" data-tone="info" data-variant="soft" data-layout="inline" role="status"><span class="cn-alert__icon" aria-hidden="true">${ICON.info}</span><div class="cn-alert__content"><div class="cn-alert__title">Keys rotate automatically every 90 days</div><p class="cn-alert__description">You’ll get an email 7 days before a rotation. Change the schedule in Security settings.</p></div></div><section class="cn-card cn-app__table-card" data-variant="default" data-padding="none"><div class="cn-card__body cn-app__card-body cn-app__card-body--flush" role="region" tabindex="0" aria-label="Scrollable table"><table class="cn-table" data-density="default" data-variant="default"><thead class="cn-table__head"><tr class="cn-table__row"><th class="cn-table__header-cell" scope="col">Name</th><th class="cn-table__header-cell" scope="col">Key</th><th class="cn-table__header-cell" scope="col">Created by</th><th class="cn-table__header-cell" scope="col">Last used</th><th class="cn-table__header-cell" scope="col"><span class="cn-sr-only">Actions</span></th></tr></thead><tbody class="cn-table__body">${[['Production', 'lum_live_…8f2a', 'DC', 'Daniel Costa', 'success', '2 minutes ago', 'Active', 'success'], ['Staging', 'lum_test_…c91d', 'AK', 'Aisha Khan', 'accent', 'Yesterday', 'Active', 'success'], ['Zapier import', 'lum_live_…41bb', 'MC', 'Maya Chen', 'accent', '34 days ago', 'Unused', 'warning'], ['Legacy CRM sync', 'lum_live_…0e77', 'LF', 'Lucas Ferreira', 'info', 'Never', 'Revoked', 'neutral']].map(([n, k, i, who, tone, used, st, stone]) => `<tr class="cn-table__row"><td class="cn-table__cell"><div class="cn-stack" data-gap="0"><span class="cn-text-label-sm">${n}</span>${BADGE(st, stone, 'soft', true)}</div></td><td class="cn-table__cell"><code class="cn-code cn-perm__key" data-variant="inline" data-size="sm">${k}</code></td><td class="cn-table__cell"><div class="cn-row" data-gap="2">${AVATAR(i, who, 'xs', tone)}<span>${who}</span></div></td><td class="cn-table__cell">${used}</td><td class="cn-table__cell cn-table__actions"><div class="cn-row" data-gap="1" data-justify="end">${BTN('ghost', 'Copy', 'sm')}${st === 'Revoked' ? BTN('ghost', 'Delete', 'sm') : BTN('danger-ghost', 'Revoke', 'sm')}</div></td></tr>`).join('')}</tbody></table></div></section></main></div>` },
21
+ ],
22
+ css: `${APP_CSS}
23
+ .cn-app__span2 { grid-column: span 2; min-width: 0; }
24
+ .cn-perm__group .cn-table__cell { background-color: {color.bg-subtle}; font-size: {font.size.xs}; font-weight: {font.weight.semibold}; color: {color.fg-muted}; padding-block: {space.2}; }
25
+ .cn-perm__table .cn-table__cell[data-align="center"], .cn-perm__table .cn-table__header-cell[data-align="center"] { text-align: center; }
26
+ .cn-perm__table .cn-checkbox { justify-content: center; }
27
+ .cn-perm__lock { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: {radius.sm}; background-color: {color.bg-muted}; color: {color.fg-muted}; }
28
+ .cn-perm__lock .cn-icon, .cn-perm__mini .cn-icon { width: 14px; height: 14px; }
29
+ .cn-perm__mini { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: {radius.full}; background-color: {color.bg-subtle}; color: {color.fg-subtle}; flex-shrink: 0; }
30
+ .cn-perm__mini[data-on="true"] { background-color: {color.bg-success-subtle}; color: {color.fg-success}; }
31
+ .cn-code.cn-perm__key { white-space: nowrap; overflow-wrap: normal; }
32
+ @media (max-width: {breakpoint.md}) {
33
+ .cn-app__span2 { grid-column: auto; }
34
+ .cn-list.cn-perm__members .cn-list__item { flex-wrap: wrap; }
35
+ .cn-list.cn-perm__members .cn-list__trailing { flex-basis: 100%; justify-content: flex-start; }
36
+ }`,
37
+ viewport: 'desktop',
38
+ };
@@ -0,0 +1,32 @@
1
+ import { SIDEBAR, BTN, TABS, AVATAR, BADGE, ICON, APP_CSS, CARD, CARD_HEAD, STAT } from "./_app.mjs";
2
+ const COVER = `<div class="cn-profile__cover"></div>`;
3
+ const HEAD = `<header class="cn-profile__head"><div class="cn-profile__identity">${AVATAR('MC', 'Maya Chen', '2xl', 'accent', 'online')}<div class="cn-stack" data-gap="1"><h1 class="cn-text-heading-lg cn-row" data-gap="2">Maya Chen ${BADGE('Admin', 'accent')}</h1><p class="cn-text-body-lg cn-app__muted">Product Manager · Florianópolis, Brazil · Joined March 2024</p></div></div><div class="cn-row" data-gap="3">${BTN('outline', 'Message', 'md', ICON.inbox)}${BTN('primary', 'Edit profile', 'md')}</div></header>`;
4
+ export const profilePage = {
5
+ name: 'Profile page',
6
+ slug: 'profile-page',
7
+ category: 'app-page',
8
+ description: 'A person or entity page: cover band, avatar + identity + actions, tabs, then a two-column body (about card + activity) or a stats row. Also the public/compact variants.',
9
+ rules: [
10
+ 'Cover band 160px in bg-action-subtle (or an image), avatar 96px (2xl) overlapping it by half, identity block right of the avatar, actions right-aligned; one primary (Edit profile / Follow).',
11
+ 'Tabs under the header: Overview, Activity, Projects, Settings.',
12
+ 'Body: 2:1 grid — main column (about card with description-list, projects table/list) and side column (stats, teams, contact).',
13
+ 'Never repeat the name in the page header; the identity block is the h1.',
14
+ ],
15
+ html: `<div class="cn-app">${SIDEBAR('Users')}<main class="cn-app__main cn-profile">${COVER}${HEAD}${TABS(['Overview', 'Activity', 'Projects', 'Settings'], 0)}<div class="cn-grid" data-cols="3" data-gap="6"><div class="cn-app__span2 cn-stack" data-gap="6">${CARD(CARD_HEAD('About'), `<dl class="cn-profile__dl"><div><dt>Email</dt><dd>maya@lumen.co</dd></div><div><dt>Role</dt><dd>Product Manager</dd></div><div><dt>Teams</dt><dd><div class="cn-row" data-gap="1">${BADGE('Product', 'info')}${BADGE('Design', 'accent')}${BADGE('Leadership')}</div></dd></div><div><dt>Time zone</dt><dd>GMT−3 · 14:22 local</dd></div><div><dt>Bio</dt><dd>I’m a product manager based in Florianópolis. I like building things that make people’s work easier.</dd></div></dl>`)}${CARD(CARD_HEAD('Recent projects', '', BTN('link-color', 'View all', 'sm')), `<ul class="cn-list" data-density="default" data-variant="plain">${[['Website redesign', 'Marketing · On track', 'success', 'On track'], ['Billing platform', 'Engineering · On track', 'success', 'On track'], ['Mobile app 2.0', 'Product · At risk', 'warning', 'At risk']].map(([t, d, tone, st]) => `<li class="cn-list__item"><span class="cn-list__content"><span class="cn-list__title">${t}</span><span class="cn-list__description">${d}</span></span><span class="cn-list__trailing">${BADGE(st, tone, 'soft', true)}</span></li>`).join('')}</ul>`)}</div><div class="cn-stack" data-gap="6"><div class="cn-grid" data-cols="1" data-gap="4">${STAT('Tasks completed', '128', '+12 this week', 'up', 'Last 30 days')}${STAT('Reviews', '24', '−3', 'down', 'Last 30 days')}</div>${CARD(CARD_HEAD('Works with'), `<div class="cn-stack" data-gap="3">${[['DC', 'Daniel Costa', 'Engineering', 'success'], ['SA', 'Sofia Almeida', 'Design', 'warning'], ['LF', 'Lucas Ferreira', 'Frontend', 'info']].map(([i, n, r, tone]) => `<div class="cn-row" data-gap="3">${AVATAR(i, n, 'sm', tone)}<div class="cn-stack" data-gap="0"><span class="cn-text-label-sm">${n}</span><span class="cn-text-body-sm cn-app__muted">${r}</span></div></div>`).join('')}</div>`)}</div></div></main></div>`,
16
+ variants: [
17
+ { title: 'Compact header with stats row', html: `<div class="cn-app">${SIDEBAR('Users', 'slim')}<main class="cn-app__main cn-profile">${HEAD.replace('2xl', 'xl').replace('cn-text-heading-lg', 'cn-text-heading-md')}<div class="cn-grid" data-cols="4" data-gap="6" data-keep>${STAT('Tasks completed', '128', '+12', 'up', 'This month')}${STAT('Open tasks', '9', '−2', 'down', 'This week')}${STAT('Reviews', '24', '+4', 'up', 'This month')}${STAT('Avg. response', '2h 10m', '−18%', 'down', 'Faster than last month')}</div>${TABS(['Overview', 'Activity', 'Projects'], 1, 'pill')}${CARD(CARD_HEAD('Activity', 'Last 7 days'), `<ul class="cn-list" data-density="compact" data-variant="plain">${[['Commented on Q3 roadmap review', '2 hours ago'], ['Closed 3 tasks in Billing platform', 'Yesterday'], ['Uploaded Brand assets v3', 'Yesterday'], ['Invited Elena Rossi to the workspace', 'Monday']].map(([t, d]) => `<li class="cn-list__item"><span class="cn-list__content"><span class="cn-list__title">${t}</span><span class="cn-list__description">${d}</span></span></li>`).join('')}</ul>`)}</main></div>` },
18
+ ],
19
+ css: `${APP_CSS}
20
+ .cn-profile { gap: {space.6}; }
21
+ .cn-profile__cover { height: 160px; border-radius: {radius.card}; background-color: {color.bg-action-subtle}; margin-bottom: -48px; }
22
+ .cn-profile__head { display: flex; justify-content: space-between; align-items: flex-end; gap: {space.4}; flex-wrap: wrap; padding-inline: {space.6}; }
23
+ .cn-profile__identity { display: flex; align-items: flex-end; gap: {space.5}; }
24
+ .cn-profile__identity .cn-avatar { box-shadow: 0 0 0 4px {color.bg-canvas}; }
25
+ .cn-profile__dl { display: grid; grid-template-columns: 160px 1fr; gap: {space.4} {space.6}; margin: 0; }
26
+ .cn-profile__dl > div { display: contents; }
27
+ .cn-profile__dl dt { font-size: {font.size.sm}; font-weight: {font.weight.medium}; color: {color.fg-muted}; }
28
+ .cn-profile__dl dd { font-size: {font.size.sm}; color: {color.fg-default}; margin: 0; }
29
+ .cn-app__span2 { grid-column: span 2; min-width: 0; }
30
+ @media (max-width: {breakpoint.md}) { .cn-app__span2 { grid-column: auto; } .cn-profile__dl { grid-template-columns: 1fr; } .cn-profile__head { padding-inline: 0; } }`,
31
+ viewport: 'desktop',
32
+ };
@@ -0,0 +1,24 @@
1
+ import { SIDEBAR, PAGE_HEADER, BTN, TABS, INPUT, TEAM_TABLE, PAGINATION, BADGE, CARD_HEAD, ICON, APP_CSS, CHECKBOX } from "./_app.mjs";
2
+ const row = (label, hint, control) => `<div class="cn-app__form-row"><div class="cn-app__form-label"><span>${label}</span><span>${hint}</span></div><div class="cn-stack" data-gap="4">${control}</div></div>`;
3
+ const HEADER = (tab) => `${PAGE_HEADER('Settings', 'Manage your account settings and preferences.', `${BTN('outline', 'Cancel', 'md')}${BTN('primary', 'Save changes', 'md')}`)}${TABS(['My details', 'Profile', 'Password', 'Team', 'Plan', 'Billing', 'Notifications', 'Integrations'], tab)}`;
4
+ export const settingsPage = {
5
+ name: 'Settings page',
6
+ slug: 'settings-page',
7
+ category: 'app-page',
8
+ description: 'Account and workspace settings: page header + tabs, then sections laid out as label-column / control-column rows separated by hairlines, with the actions at the end. Variants: profile form, team, plan & billing, notifications.',
9
+ rules: [
10
+ 'Tabs under the page header select the settings area; the content is a single 1024px-max column.',
11
+ 'Each setting is a row: left column 280px with a semibold label and a one-line hint; right column the control(s), max 512px. Rows separated by 20px padding and a hairline.',
12
+ 'Section header (heading-sm + description + actions) at the top of each area; the save actions repeat at the bottom of long forms.',
13
+ 'Destructive actions (delete account) sit in their own last section with a danger-outline button.',
14
+ 'Team: section header with count badge, a table with checkbox, avatar, status, role, email and actions, then pagination.',
15
+ ],
16
+ html: `<div class="cn-app">${SIDEBAR('Settings')}<main class="cn-app__main">${HEADER(1)}<div class="cn-stack" data-gap="0" style="max-width:1024px"><div class="cn-section-header" data-variant="default" data-divider="yes">${CARD_HEAD('Personal info', 'Update your photo and personal details here.', `${BTN('outline', 'Cancel', 'sm')}${BTN('primary', 'Save', 'sm')}`)}</div>${row('Name', 'Shown on your profile and in mentions.', `<div class="cn-grid" data-cols="2" data-gap="4" data-keep>${INPUT('s-first', 'Maya')}${INPUT('s-last', 'Chen')}</div>`)}${row('Email address', 'Used for sign-in and notifications.', INPUT('s-email', 'maya@lumen.co', 'email', '', ICON.inbox))}${row('Your photo', 'This will be displayed on your profile.', `<div class="cn-row" data-gap="5" data-align="start"><span class="cn-avatar" data-size="2xl" data-shape="circle" data-tone="accent" data-status="none" role="img" aria-label="Maya Chen"><span class="cn-avatar__fallback" aria-hidden="true">MC</span></span><div class="cn-file-dropzone cn-flex-1" data-size="sm"><input class="cn-file-dropzone__input" type="file" aria-label="Upload photo"><span class="cn-file-dropzone__icon" aria-hidden="true">${ICON.copy}</span><span class="cn-file-dropzone__title"><strong>Click to upload</strong> or drag and drop</span><span class="cn-file-dropzone__hint">SVG, PNG, JPG or GIF (max. 800×400px)</span></div></div>`)}${row('Role', 'Your title inside the workspace.', INPUT('s-role', 'Product Manager'))}${row('Country', 'Where you are based, for tax and time zone.', `<div class="cn-select" data-variant="default" data-size="md"><select class="cn-select__field" id="s-country" aria-label="Country"><option>Brazil</option><option selected>United States</option><option>Portugal</option></select><svg class="cn-select__chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></div>`)}${row('Bio', 'Write a short introduction.', `<div class="cn-textarea" data-size="md" data-resize="vertical"><textarea class="cn-textarea__field" rows="4" aria-label="Bio">I’m a product manager based in Florianópolis. I like building things that make people’s work easier.</textarea></div><span class="cn-text-body-sm cn-app__muted">275 characters left</span>`)}<div class="cn-app__form-actions">${BTN('outline', 'Cancel', 'md')}${BTN('primary', 'Save changes', 'md')}</div></div></main></div>`,
17
+ variants: [
18
+ { title: 'Team', html: `<div class="cn-app">${SIDEBAR('Settings')}<main class="cn-app__main">${HEADER(3)}<div class="cn-stack" data-gap="6"><div class="cn-section-header" data-variant="with-badge" data-divider="yes">${CARD_HEAD('Team members', 'Manage your team members and their account permissions here.', `${BTN('outline', 'Download CSV', 'md')}${BTN('primary', 'Add team member', 'md', ICON.plus)}`, BADGE('100 users', 'accent'))}</div><section class="cn-card cn-app__table-card" data-variant="default" data-padding="none"><div class="cn-card__body cn-app__card-body cn-app__card-body--flush" role="region" tabindex="0" aria-label="Scrollable table">${TEAM_TABLE()}</div><footer class="cn-card__footer">${PAGINATION}</footer></section></div></main></div>` },
19
+ { title: 'Plan and billing', html: `<div class="cn-app">${SIDEBAR('Settings')}<main class="cn-app__main">${HEADER(4)}<div class="cn-stack" data-gap="8" style="max-width:1024px"><div class="cn-section-header" data-variant="default" data-divider="yes">${CARD_HEAD('Account plans', 'Pick an account plan that fits your workflow.', BTN('outline', 'Compare plans', 'sm'))}</div><div class="cn-grid" data-cols="3" data-gap="6">${[['Basic plan', '$10', 'Includes up to 10 users, 20GB individual data and access to all features.', false], ['Business plan', '$20', 'Includes up to 20 users, 40GB individual data and priority support.', true], ['Enterprise plan', '$40', 'Unlimited users, unlimited data and personalised service.', false]].map(([n, p, d, cur]) => `<section class="cn-card" data-variant="default" data-padding="md"${cur ? ' data-selected' : ''}><div class="cn-card__body cn-app__plan"><div class="cn-row" data-justify="between" data-align="start"><div class="cn-stack" data-gap="1"><span class="cn-text-heading-xs">${n}</span><span class="cn-text-body-sm cn-app__muted">${d}</span></div>${cur ? BADGE('Current', 'accent') : ''}</div><span class="cn-text-heading-lg">${p}<span class="cn-text-body-sm cn-app__muted"> per month</span></span>${cur ? BTN('outline', 'Manage plan', 'md') : BTN('primary', 'Upgrade', 'md')}</div></section>`).join('')}</div><div class="cn-section-header" data-variant="default" data-divider="yes">${CARD_HEAD('Billing and invoicing', 'Pick an account plan that fits your workflow.', `${BTN('outline', 'Download all', 'sm')}`)}</div><section class="cn-card cn-app__table-card" data-variant="default" data-padding="none"><div class="cn-card__body cn-app__card-body cn-app__card-body--flush" role="region" tabindex="0" aria-label="Scrollable table"><table class="cn-table" data-density="default" data-variant="default"><thead class="cn-table__head"><tr class="cn-table__row"><th class="cn-table__header-cell" scope="col">Invoice</th><th class="cn-table__header-cell" scope="col">Billing date</th><th class="cn-table__header-cell" scope="col">Status</th><th class="cn-table__header-cell" scope="col" data-align="right">Amount</th><th class="cn-table__header-cell" scope="col"><span class="cn-sr-only">Actions</span></th></tr></thead><tbody class="cn-table__body">${['Dec 2026', 'Nov 2026', 'Oct 2026', 'Sep 2026'].map((m, i) => `<tr class="cn-table__row"><td class="cn-table__cell"><span class="cn-text-label-sm">Invoice #${1084 - i} – ${m}</span></td><td class="cn-table__cell">${m}</td><td class="cn-table__cell">${BADGE('Paid', 'success', 'soft', true)}</td><td class="cn-table__cell" data-align="right">USD $20.00</td><td class="cn-table__cell cn-table__actions">${BTN('link-color', 'Download', 'sm')}</td></tr>`).join('')}</tbody></table></div></section></div></main></div>` },
20
+ { title: 'Notifications', html: `<div class="cn-app">${SIDEBAR('Settings')}<main class="cn-app__main">${HEADER(6)}<div class="cn-stack" data-gap="0" style="max-width:1024px"><div class="cn-section-header" data-variant="default" data-divider="yes">${CARD_HEAD('Notification settings', 'We may still send you important notifications about your account outside of your notification settings.')}</div>${[['Comments', 'These are notifications for comments on your posts and replies to your comments.'], ['Tags', 'These are notifications for when someone tags you in a comment, post or story.'], ['Reminders', 'These are notifications to remind you of updates you might have missed.'], ['More activity about you', 'These are notifications for posts on your profile, likes and other reactions.']].map(([l, h]) => row(l, h, `<label class="cn-switch" data-size="sm"><input class="cn-switch__input" type="checkbox" role="switch" checked><span class="cn-switch__track" aria-hidden="true"><span class="cn-switch__thumb"></span></span><span class="cn-switch__label">Push</span></label><label class="cn-switch" data-size="sm"><input class="cn-switch__input" type="checkbox" role="switch" checked><span class="cn-switch__track" aria-hidden="true"><span class="cn-switch__thumb"></span></span><span class="cn-switch__label">Email</span></label><label class="cn-switch" data-size="sm"><input class="cn-switch__input" type="checkbox" role="switch"><span class="cn-switch__track" aria-hidden="true"><span class="cn-switch__thumb"></span></span><span class="cn-switch__label">SMS</span></label>`)).join('')}<div class="cn-app__form-row"><div class="cn-app__form-label"><span>Danger zone</span><span>Permanently delete your account and all data.</span></div><div class="cn-stack" data-gap="3" data-align="start">${CHECKBOX('I understand this cannot be undone')}${BTN('danger-outline', 'Delete account', 'md')}</div></div></div></main></div>` },
21
+ ],
22
+ css: APP_CSS,
23
+ viewport: 'desktop',
24
+ };
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { sourcePath } from "./design-files.mjs";
4
+ const CONFIG = '.canon/project.json';
5
+ /** Discover a project from any working directory below it. */
6
+ export function findProject(from) {
7
+ let root = resolve(from);
8
+ while (true) {
9
+ const path = join(root, CONFIG);
10
+ if (existsSync(path)) {
11
+ const canonical = realpathSync(root);
12
+ sourcePath(canonical, join(canonical, CONFIG), 'file');
13
+ const config = JSON.parse(readFileSync(path, 'utf8'));
14
+ if (config?.version !== 1 || typeof config.design !== 'string' || !config.design || isAbsolute(config.design) || config.design.includes('\\') || config.design.includes('\0')) {
15
+ throw new Error(`Invalid Canon project configuration: ${path}`);
16
+ }
17
+ return { root: canonical, design: resolve(canonical, config.design) };
18
+ }
19
+ const parent = dirname(root);
20
+ if (parent === root)
21
+ return undefined;
22
+ root = parent;
23
+ }
24
+ }
25
+ export function projectWrite(root, design) {
26
+ const config = { version: 1, design: relative(root, design).split(sep).join('/') || '.' };
27
+ return { root, path: join(root, CONFIG), content: Buffer.from(JSON.stringify(config, null, 2) + '\n') };
28
+ }
package/lib/serve.mjs ADDED
@@ -0,0 +1,279 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFileSync, statSync, realpathSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { basename, join, resolve, relative, extname } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { validateSystem } from "./system.mjs";
6
+ import { HttpError, requireValue, record, identifier, validateMeta, contained, sourcePath, loadSource, installFiles } from "./design-files.mjs";
7
+ import { buildSystem } from "./build.mjs";
8
+ import { compareSpecs } from "./components/index.mjs";
9
+ import { comparePatterns } from "./patterns/index.mjs";
10
+ import { getCanonPackage } from "./distribution.mjs";
11
+ import { referenceWrites } from "./install.mjs";
12
+ import { findProject } from "./project.mjs";
13
+ const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript', '.json': 'application/json', '.md': 'text/markdown; charset=utf-8', '.tsx': 'text/plain; charset=utf-8', '.svg': 'image/svg+xml' };
14
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
15
+ function checkRequest(req) {
16
+ const port = req.socket.localPort;
17
+ const hosts = ['localhost', '127.0.0.1', '[::1]'].map((hostname) => `${hostname}:${port}`);
18
+ if (port === 80)
19
+ hosts.push('localhost', '127.0.0.1', '[::1]');
20
+ const host = req.headers.host?.toLowerCase();
21
+ const hostCount = req.rawHeaders.filter((_, index) => index % 2 === 0 && req.rawHeaders[index].toLowerCase() === 'host').length;
22
+ if (!host || hostCount !== 1 || !hosts.includes(host))
23
+ throw new HttpError(403, 'Host must address this local Studio server');
24
+ if (req.headers.origin !== undefined && req.headers.origin !== `http://${host}`)
25
+ throw new HttpError(403, 'Origin must match this Studio server');
26
+ }
27
+ function readBody(req) {
28
+ if (!/^application\/json(?:\s*;|\s*$)/i.test(req.headers['content-type'] ?? ''))
29
+ throw new HttpError(415, 'Save requires application/json');
30
+ if (Number(req.headers['content-length'] ?? 0) > MAX_BODY_BYTES)
31
+ throw new HttpError(413, 'Save body exceeds 8 MiB');
32
+ return new Promise((resolveBody, reject) => {
33
+ let chunks = [];
34
+ let size = 0;
35
+ let done = false;
36
+ const fail = (error) => { if (!done) {
37
+ done = true;
38
+ chunks = [];
39
+ reject(error);
40
+ } };
41
+ req.on('error', fail);
42
+ req.on('aborted', () => fail(new HttpError(400, 'Save request was interrupted')));
43
+ req.on('data', (chunk) => {
44
+ if (done)
45
+ return;
46
+ size += chunk.length;
47
+ if (size > MAX_BODY_BYTES) {
48
+ fail(new HttpError(413, 'Save body exceeds 8 MiB'));
49
+ return;
50
+ }
51
+ chunks.push(chunk);
52
+ });
53
+ req.on('end', () => {
54
+ if (done)
55
+ return;
56
+ done = true;
57
+ try {
58
+ resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8')));
59
+ }
60
+ catch {
61
+ reject(new HttpError(400, 'Save body is not valid JSON'));
62
+ }
63
+ });
64
+ });
65
+ }
66
+ function validateBody(value) {
67
+ const body = record(value, 'Save body');
68
+ for (const key of Object.keys(body))
69
+ requireValue(['meta', 'tokens', 'components', 'patterns'].includes(key), `Unknown save field: ${key}`);
70
+ for (const field of ['meta', 'tokens'])
71
+ if (field in body)
72
+ record(body[field], field);
73
+ for (const collection of ['components', 'patterns']) {
74
+ if (!(collection in body))
75
+ continue;
76
+ for (const [slug, value] of Object.entries(record(body[collection], collection))) {
77
+ identifier(slug, `${collection} key`);
78
+ const spec = record(value, `${collection}.${slug}`);
79
+ requireValue(spec.slug === slug, `${collection}.${slug}.slug must match its key`);
80
+ }
81
+ }
82
+ return body;
83
+ }
84
+ async function saveSource(body, designRoot, outputRoot, projectRoot) {
85
+ const trial = loadSource(designRoot);
86
+ if (body.meta !== undefined)
87
+ trial.meta = body.meta;
88
+ if (body.tokens !== undefined)
89
+ trial.tokens = body.tokens;
90
+ for (const [slug, spec] of Object.entries(body.components ?? {})) {
91
+ const index = trial.components.findIndex((component) => component.slug === slug);
92
+ if (index === -1)
93
+ trial.components.push(spec);
94
+ else
95
+ trial.components[index] = spec;
96
+ }
97
+ for (const [slug, pattern] of Object.entries(body.patterns ?? {})) {
98
+ const index = trial.patterns.findIndex((item) => item.slug === slug);
99
+ if (index === -1)
100
+ trial.patterns.push(pattern);
101
+ else
102
+ trial.patterns[index] = pattern;
103
+ }
104
+ validateMeta(trial.meta, designRoot, outputRoot);
105
+ for (const item of [...trial.components, ...trial.patterns])
106
+ identifier(item.slug, 'Design slug');
107
+ trial.components.sort(compareSpecs);
108
+ trial.patterns.sort(comparePatterns);
109
+ sourcePath(designRoot, outputRoot, 'directory');
110
+ try {
111
+ const validation = validateSystem(trial);
112
+ if (validation.errors.length)
113
+ throw new Error(validation.errors.slice(0, 5).join('; '));
114
+ }
115
+ catch (error) {
116
+ throw new HttpError(422, error.message);
117
+ }
118
+ const stage = mkdtempSync(join(tmpdir(), 'canon-studio-save-'));
119
+ try {
120
+ // Generation can fail after producing files; build away from the live source/output.
121
+ let result;
122
+ try {
123
+ result = await buildSystem(trial, stage);
124
+ }
125
+ catch (error) {
126
+ throw new HttpError(422, error.message);
127
+ }
128
+ const writes = result.files.map((file) => {
129
+ contained(result.outDir, file);
130
+ contained(realpathSync(result.outDir), realpathSync(file));
131
+ return { path: join(outputRoot, relative(result.outDir, file)), content: readFileSync(file) };
132
+ });
133
+ const sourceWrite = (rel, value) => writes.push({ path: join(designRoot, rel), content: Buffer.from(JSON.stringify(value, null, 2) + '\n') });
134
+ if (body.meta !== undefined)
135
+ sourceWrite('system.json', body.meta);
136
+ if (body.tokens !== undefined)
137
+ sourceWrite('tokens.json', body.tokens);
138
+ for (const [slug, spec] of Object.entries(body.components ?? {}))
139
+ sourceWrite(`components/${slug}.json`, spec);
140
+ for (const [slug, pattern] of Object.entries(body.patterns ?? {}))
141
+ sourceWrite(`patterns/${slug}.json`, pattern);
142
+ if (projectRoot)
143
+ writes.push(...referenceWrites(trial, designRoot, projectRoot, result.outDir));
144
+ // Publish the revision after source and references so MCP can load a complete save.
145
+ const manifest = writes.findIndex(write => write.path === join(outputRoot, 'canon.lock.json'));
146
+ if (manifest >= 0)
147
+ writes.push(...writes.splice(manifest, 1));
148
+ // Recheck after the asynchronous build before committing to the project.
149
+ loadSource(designRoot);
150
+ sourcePath(designRoot, outputRoot, 'directory');
151
+ installFiles(designRoot, writes);
152
+ return { ok: true, built: result.files.length, referencesUpdated: Boolean(projectRoot), warnings: result.warnings };
153
+ }
154
+ finally {
155
+ rmSync(stage, { recursive: true, force: true });
156
+ }
157
+ }
158
+ function json(res, status, body) {
159
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
160
+ res.end(JSON.stringify(body));
161
+ }
162
+ export function serve(dir, port = 4600, designDir, opts = {}) {
163
+ return new Promise((resolveServe, reject) => {
164
+ const outputRoot = realpathSync(resolve(dir));
165
+ const designRoot = designDir ? realpathSync(resolve(designDir)) : undefined;
166
+ const projectRoot = opts.projectRoot ? realpathSync(resolve(opts.projectRoot)) : undefined;
167
+ const checkProject = () => {
168
+ if (!projectRoot)
169
+ return;
170
+ const project = findProject(projectRoot);
171
+ requireValue(project?.root === projectRoot && project.design === designRoot, 'This project no longer points to this Studio design; reopen its Studio');
172
+ };
173
+ checkProject();
174
+ if (designRoot) {
175
+ contained(designRoot, outputRoot);
176
+ requireValue(designRoot !== outputRoot, 'Studio output must be a separate directory within the design directory');
177
+ }
178
+ let saving = false;
179
+ const server = createServer(async (req, res) => {
180
+ res.setHeader('x-content-type-options', 'nosniff');
181
+ try {
182
+ checkRequest(req);
183
+ let url;
184
+ try {
185
+ url = decodeURIComponent((req.url ?? '/').split('?')[0]);
186
+ }
187
+ catch {
188
+ throw new HttpError(400, 'Malformed request URL');
189
+ }
190
+ requireValue(url.startsWith('/') && !url.startsWith('//') && !url.includes('\0') && !url.includes('\\'), 'Malformed request URL');
191
+ if (url === '/api/project') {
192
+ if (req.method !== 'GET')
193
+ throw new HttpError(405, 'Use GET for /api/project');
194
+ checkProject();
195
+ json(res, 200, { connected: Boolean(projectRoot), name: projectRoot ? basename(projectRoot) : undefined });
196
+ return;
197
+ }
198
+ if (designRoot && url === '/api/system') {
199
+ if (req.method !== 'GET')
200
+ throw new HttpError(405, 'Use GET for /api/system');
201
+ json(res, 200, loadSource(designRoot));
202
+ return;
203
+ }
204
+ if (designRoot && url === '/api/save') {
205
+ if (req.method !== 'POST')
206
+ throw new HttpError(405, 'Use POST for /api/save');
207
+ checkProject();
208
+ const body = validateBody(await readBody(req));
209
+ if (saving)
210
+ throw new HttpError(409, 'A save is already in progress; retry when it finishes');
211
+ saving = true;
212
+ try {
213
+ const result = await saveSource(body, designRoot, outputRoot, projectRoot);
214
+ json(res, 200, result);
215
+ console.log(`saved → rebuilt ${result.built} files`);
216
+ }
217
+ finally {
218
+ saving = false;
219
+ }
220
+ return;
221
+ }
222
+ if (req.method !== 'GET' && req.method !== 'HEAD')
223
+ throw new HttpError(405, 'Use GET or HEAD for static files');
224
+ if (url === '/setup') {
225
+ res.writeHead(302, { location: '/docs.html#primeiro-projeto', 'cache-control': 'no-store', 'content-length': '0' });
226
+ res.end();
227
+ return;
228
+ }
229
+ if (url === '/canon-package.tgz') {
230
+ const content = await getCanonPackage();
231
+ res.writeHead(200, {
232
+ 'content-type': 'application/gzip', 'content-disposition': 'attachment; filename="canon-package.tgz"',
233
+ 'content-length': content.byteLength, 'cache-control': 'no-store',
234
+ });
235
+ res.end(req.method === 'HEAD' ? undefined : content);
236
+ return;
237
+ }
238
+ let file = resolve(outputRoot, url === '/' ? 'preview.html' : `.${url}`);
239
+ contained(outputRoot, file);
240
+ file = realpathSync(file);
241
+ contained(outputRoot, file);
242
+ if (statSync(file).isDirectory()) {
243
+ file = realpathSync(join(file, 'index.html'));
244
+ contained(outputRoot, file);
245
+ }
246
+ if (!statSync(file).isFile())
247
+ throw new HttpError(404, 'File not found');
248
+ const content = readFileSync(file);
249
+ res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-store' });
250
+ res.end(req.method === 'HEAD' ? undefined : content);
251
+ }
252
+ catch (error) {
253
+ const code = error.code;
254
+ const status = error instanceof HttpError ? error.status : code === 'ENOENT' || code === 'ENOTDIR' ? 404 : code === 'EACCES' || code === 'EPERM' ? 403 : 500;
255
+ if (!req.complete) {
256
+ res.setHeader('connection', 'close');
257
+ req.resume();
258
+ }
259
+ if (!res.destroyed)
260
+ json(res, status, { ok: false, error: error instanceof Error ? error.message : 'Studio request failed' });
261
+ }
262
+ });
263
+ server.requestTimeout = 30_000;
264
+ server.headersTimeout = 10_000;
265
+ const stop = () => server.close();
266
+ const cleanup = () => process.off('SIGINT', stop);
267
+ server.once('error', (error) => { cleanup(); reject(error); });
268
+ server.once('close', () => { cleanup(); resolveServe(); });
269
+ process.once('SIGINT', stop);
270
+ server.listen(port, '127.0.0.1', () => {
271
+ const address = server.address();
272
+ console.log(`canon studio → http://127.0.0.1:${address.port}/ (serving ${dir}${designDir ? ', saving to ' + designDir : ''})`);
273
+ if (opts.open) {
274
+ import("./open.mjs").then(({ openBrowser }) => openBrowser(`http://127.0.0.1:${address.port}/`))
275
+ .catch(error => console.error(`Studio is ready; could not open a browser: ${error.message}`));
276
+ }
277
+ });
278
+ });
279
+ }