@nqmcreative/ui 0.3.0 → 0.4.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 (72) hide show
  1. package/README.md +209 -15
  2. package/cli/index.mjs +247 -70
  3. package/dist/core/color.d.ts +24 -0
  4. package/dist/core/color.js +39 -0
  5. package/dist/core/index.d.ts +8 -1
  6. package/dist/core/index.js +5 -1
  7. package/dist/core/locale.svelte.d.ts +8 -0
  8. package/dist/core/locale.svelte.js +14 -0
  9. package/dist/core/number.d.ts +22 -0
  10. package/dist/core/number.js +33 -0
  11. package/dist/core/pin.d.ts +23 -0
  12. package/dist/core/pin.js +38 -0
  13. package/dist/core/tags.d.ts +37 -0
  14. package/dist/core/tags.js +51 -0
  15. package/dist/core/time.d.ts +30 -0
  16. package/dist/core/time.js +78 -0
  17. package/dist/styles/matte/ColorInput.svelte +110 -0
  18. package/dist/styles/matte/ColorInput.svelte.d.ts +16 -0
  19. package/dist/styles/matte/CurrencyInput.svelte +108 -0
  20. package/dist/styles/matte/CurrencyInput.svelte.d.ts +26 -0
  21. package/dist/styles/matte/FileInput.svelte +194 -0
  22. package/dist/styles/matte/FileInput.svelte.d.ts +32 -0
  23. package/dist/styles/matte/InputAddon.svelte +36 -0
  24. package/dist/styles/matte/InputAddon.svelte.d.ts +13 -0
  25. package/dist/styles/matte/PinInput.svelte +137 -0
  26. package/dist/styles/matte/PinInput.svelte.d.ts +25 -0
  27. package/dist/styles/matte/SearchInput.svelte +87 -0
  28. package/dist/styles/matte/SearchInput.svelte.d.ts +24 -0
  29. package/dist/styles/matte/TagsInput.svelte +173 -0
  30. package/dist/styles/matte/TagsInput.svelte.d.ts +29 -0
  31. package/dist/styles/matte/TimeInput.svelte +129 -0
  32. package/dist/styles/matte/TimeInput.svelte.d.ts +21 -0
  33. package/dist/styles/matte/index.d.ts +8 -0
  34. package/dist/styles/matte/index.js +8 -0
  35. package/dist/styles/paper/ColorInput.svelte +112 -0
  36. package/dist/styles/paper/ColorInput.svelte.d.ts +16 -0
  37. package/dist/styles/paper/CurrencyInput.svelte +108 -0
  38. package/dist/styles/paper/CurrencyInput.svelte.d.ts +26 -0
  39. package/dist/styles/paper/FileInput.svelte +204 -0
  40. package/dist/styles/paper/FileInput.svelte.d.ts +32 -0
  41. package/dist/styles/paper/InputAddon.svelte +36 -0
  42. package/dist/styles/paper/InputAddon.svelte.d.ts +13 -0
  43. package/dist/styles/paper/PinInput.svelte +137 -0
  44. package/dist/styles/paper/PinInput.svelte.d.ts +25 -0
  45. package/dist/styles/paper/SearchInput.svelte +92 -0
  46. package/dist/styles/paper/SearchInput.svelte.d.ts +24 -0
  47. package/dist/styles/paper/TagsInput.svelte +178 -0
  48. package/dist/styles/paper/TagsInput.svelte.d.ts +29 -0
  49. package/dist/styles/paper/TimeInput.svelte +155 -0
  50. package/dist/styles/paper/TimeInput.svelte.d.ts +21 -0
  51. package/dist/styles/paper/index.d.ts +8 -0
  52. package/dist/styles/paper/index.js +8 -0
  53. package/dist/styles/sprout/ColorInput.svelte +112 -0
  54. package/dist/styles/sprout/ColorInput.svelte.d.ts +16 -0
  55. package/dist/styles/sprout/CurrencyInput.svelte +108 -0
  56. package/dist/styles/sprout/CurrencyInput.svelte.d.ts +26 -0
  57. package/dist/styles/sprout/FileInput.svelte +205 -0
  58. package/dist/styles/sprout/FileInput.svelte.d.ts +32 -0
  59. package/dist/styles/sprout/InputAddon.svelte +37 -0
  60. package/dist/styles/sprout/InputAddon.svelte.d.ts +13 -0
  61. package/dist/styles/sprout/PinInput.svelte +138 -0
  62. package/dist/styles/sprout/PinInput.svelte.d.ts +25 -0
  63. package/dist/styles/sprout/SearchInput.svelte +92 -0
  64. package/dist/styles/sprout/SearchInput.svelte.d.ts +24 -0
  65. package/dist/styles/sprout/TagsInput.svelte +179 -0
  66. package/dist/styles/sprout/TagsInput.svelte.d.ts +29 -0
  67. package/dist/styles/sprout/TimeInput.svelte +155 -0
  68. package/dist/styles/sprout/TimeInput.svelte.d.ts +21 -0
  69. package/dist/styles/sprout/index.d.ts +8 -0
  70. package/dist/styles/sprout/index.js +8 -0
  71. package/package.json +98 -2
  72. package/registry.json +143 -1
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @nqmcreative/ui
2
2
 
3
3
  Svelte 5 (runes) + Tailwind CSS v4 component library. One package, **three
4
- styles**, 63 components each — the same props, the same behaviour, a different
4
+ styles**, 71 components each — the same props, the same behaviour, a different
5
5
  look. Eight tones, light and dark, no runtime dependencies.
6
6
 
7
7
  | style | import | character |
@@ -65,6 +65,131 @@ preconnect plus the no-flash theme script in `src/app.html`, above
65
65
 
66
66
  </details>
67
67
 
68
+ ## Without SvelteKit
69
+
70
+ Nothing in the library imports `$app/*`, `$env/*` or `@sveltejs/kit`. The only
71
+ peer dependency is `svelte` ^5, and every DOM call sits inside an action or an
72
+ effect — so anything that compiles Svelte components runs these: a plain Vite
73
+ app, or a Laravel app with Vite as its front end.
74
+
75
+ The components ship as `.svelte` source, published under a `svelte` export
76
+ condition. `@sveltejs/vite-plugin-svelte` is what resolves that condition and
77
+ compiles them, so it is required outside SvelteKit. `nqm-ui init` detects which
78
+ of the three project shapes it is in and wires the files that shape uses.
79
+
80
+ ### Svelte + Vite
81
+
82
+ ```bash
83
+ npm create vite@latest myapp -- --template svelte-ts
84
+ cd myapp
85
+ npm i @nqmcreative/ui
86
+ npm i -D tailwindcss @tailwindcss/vite
87
+ npx nqm-ui init --style matte
88
+ ```
89
+
90
+ `init` writes `src/app.css`, patches the `<head>` in `index.html`, and adds the
91
+ CSS import to `src/main.ts`. The Vite config is the one thing it prints rather
92
+ than rewrites:
93
+
94
+ ```ts
95
+ // vite.config.ts
96
+ import { defineConfig } from 'vite';
97
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
98
+ import tailwindcss from '@tailwindcss/vite';
99
+
100
+ export default defineConfig({
101
+ plugins: [tailwindcss(), svelte()]
102
+ });
103
+ ```
104
+
105
+ ### Laravel
106
+
107
+ Vite is already how Laravel builds its front end, so Svelte and this package are
108
+ the only additions.
109
+
110
+ ```bash
111
+ npm i @nqmcreative/ui
112
+ npm i -D svelte @sveltejs/vite-plugin-svelte tailwindcss @tailwindcss/vite
113
+ npx nqm-ui init --style matte
114
+ ```
115
+
116
+ ```js
117
+ // vite.config.js
118
+ import { defineConfig } from 'vite';
119
+ import laravel from 'laravel-vite-plugin';
120
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
121
+ import tailwindcss from '@tailwindcss/vite';
122
+
123
+ export default defineConfig({
124
+ plugins: [
125
+ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true }),
126
+ tailwindcss(),
127
+ svelte()
128
+ ]
129
+ });
130
+ ```
131
+
132
+ The entry CSS is `resources/css/app.css`, two levels below the project root, so
133
+ `@source` walks two up. Laravel keeps its markup outside that folder, so point
134
+ Tailwind at your own files too — otherwise the classes you write in Blade and in
135
+ your Svelte islands go missing the same way the package's would:
136
+
137
+ ```css
138
+ /* resources/css/app.css */
139
+ @import 'tailwindcss';
140
+ @import '@nqmcreative/ui/matte/theme.css';
141
+ @import '@nqmcreative/ui/matte/fonts.css';
142
+
143
+ @source '../../node_modules/@nqmcreative/ui/dist/styles/matte';
144
+ @source '../js';
145
+ @source '../views';
146
+ ```
147
+
148
+ Nothing imports that CSS from JavaScript — Blade hands the path to Vite. The
149
+ preconnect and the no-flash theme script go in the same `<head>`, which is what
150
+ `init` patches:
151
+
152
+ ```blade
153
+ {{-- resources/views/app.blade.php --}}
154
+ <head>
155
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
156
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
157
+ <script>
158
+ const saved = localStorage.getItem('nqm-theme');
159
+ if (saved === 'dark' || saved === 'light') document.documentElement.classList.add(saved);
160
+ </script>
161
+ </head>
162
+ ```
163
+
164
+ Then mount Svelte wherever the page needs it — one root component, or an island
165
+ per view:
166
+
167
+ ```js
168
+ // resources/js/app.js
169
+ import { mount } from 'svelte';
170
+ import App from './App.svelte';
171
+
172
+ const target = document.getElementById('app');
173
+ if (target) mount(App, { target });
174
+ ```
175
+
176
+ ```svelte
177
+ <!-- resources/js/App.svelte -->
178
+ <script>
179
+ import { Button, Field, Input } from '@nqmcreative/ui/matte';
180
+
181
+ let email = $state('');
182
+ </script>
183
+
184
+ <Field label="Work email">
185
+ <Input bind:value={email} placeholder="you@example.com" />
186
+ </Field>
187
+ <Button tone="accent">Get started</Button>
188
+ ```
189
+
190
+ Blade renders the shell, Svelte renders the component — the two never fight over
191
+ the same node, because the mount target is the only thing they share.
192
+
68
193
  ## Use
69
194
 
70
195
  The import names the style:
@@ -91,6 +216,75 @@ to nothing, on purpose: a style is a choice, and picking one by accident is the
91
216
  mistake this layout exists to prevent. Switching later is a find-and-replace of
92
217
  the word after `@nqmcreative/ui/`.
93
218
 
219
+ ### Form inputs
220
+
221
+ `Input` takes any `type`, so an email or a URL field is a prop away, not a
222
+ component:
223
+
224
+ ```svelte
225
+ <Input type="email" inputmode="email" autocomplete="email" />
226
+ ```
227
+
228
+ The eight below exist because each adds behaviour a `type` cannot — a second
229
+ value, a keyboard rule, or a format that has to survive a round trip. All of
230
+ them take `size`, `tone`, `invalid` and `disabled`, and mean the same by them as
231
+ `Input` does.
232
+
233
+ | component | binds | what it adds |
234
+ | --------------- | ---------------- | ------------------------------------------------------------------ |
235
+ | `SearchInput` | `string` | Clear button, plus `onsearch` debounced separately from the value. |
236
+ | `InputAddon` | — | Fixed label welded to an `InputGroup`: `https://`, `.com`, `kg`. |
237
+ | `PinInput` | `string` | One box per character, paste spreading, arrow keys, `oncomplete`. |
238
+ | `TagsInput` | `string[]` | Free-text tokens on Enter, deduplicated, capped, split on paste. |
239
+ | `CurrencyInput` | `number \| null` | Grouping applied on blur, so the bound value stays a number. |
240
+ | `TimeInput` | `string` | Steppers and bounds over an `HH:MM` string. |
241
+ | `ColorInput` | `string` | Native swatch and hex field kept in step, normalised to `#rrggbb`. |
242
+ | `FileInput` | `File[]` | One-line picker with `Dropzone`'s type, size and count validation. |
243
+
244
+ Four of them wrap `Input` and fill its prefix or suffix slot — `SearchInput`,
245
+ `CurrencyInput`, `TimeInput`, `ColorInput` — so a change to the field's border
246
+ or focus ring reaches them for free. The other four draw their own box.
247
+
248
+ ```svelte
249
+ <script lang="ts">
250
+ import { CurrencyInput, PinInput, TagsInput, TimeInput } from '@nqmcreative/ui/matte';
251
+
252
+ let price = $state<number | null>(1250000); // a number, not a formatted string
253
+ let code = $state(''); // shorter than `length` means incomplete
254
+ let tags = $state(['svelte']);
255
+ let start = $state('09:00'); // what <input type="time"> would post
256
+ </script>
257
+
258
+ <CurrencyInput bind:value={price} currency="Rp" group="." decimal="," precision={0} />
259
+ <PinInput bind:value={code} length={6} groupAfter={3} oncomplete={verify} />
260
+ <TagsInput bind:tags max={6} onreject={(reason) => toast.error(reason)} />
261
+ <TimeInput bind:value={start} min="08:00" max="17:00" step={15} />
262
+ ```
263
+
264
+ Two details worth knowing before you wire them up. `CurrencyInput` groups on
265
+ blur and not under the caret, because formatting as you type is what pushes the
266
+ caret around mid-word. And `TagsInput` reports every refusal — duplicate, too
267
+ short, list full — through `onreject`, because a field that silently ignores
268
+ Enter reads as broken.
269
+
270
+ The rules are all in `@nqmcreative/ui/core`, not in any style:
271
+
272
+ ```ts
273
+ import {
274
+ addTag,
275
+ splitTags, // tokens
276
+ fillFrom,
277
+ sanitisePin, // codes
278
+ parseTime,
279
+ clampTime, // clock
280
+ formatGrouped,
281
+ parseGrouped, // money
282
+ normaliseHex, // colour
283
+ sortFiles,
284
+ formatSize // files
285
+ } from '@nqmcreative/ui/core';
286
+ ```
287
+
94
288
  ### Tones
95
289
 
96
290
  Eight of them — `brand`, `accent`, `violet`, `info`, `success`, `warning`,
@@ -131,9 +325,9 @@ Under SSR use `<LocaleProvider>` instead, so the locale is scoped to a request.
131
325
 
132
326
  ```
133
327
  src/lib/core/ behaviour, a11y, types — no markup, no classes
134
- src/lib/styles/matte/ 63 components
135
- src/lib/styles/paper/ 63 components
136
- src/lib/styles/sprout/ 63 components
328
+ src/lib/styles/matte/ 71 components
329
+ src/lib/styles/paper/ 71 components
330
+ src/lib/styles/sprout/ 71 components
137
331
  scripts/catalogue.mjs the list every style must implement
138
332
  ```
139
333
 
@@ -150,17 +344,17 @@ same.
150
344
 
151
345
  ## Components
152
346
 
153
- | group | components |
154
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
155
- | Actions | `Button`, `Link` |
156
- | Forms | `Input`, `Textarea`, `Select`, `Combobox`, `MultiSelect`, `DatePicker`, `Calendar`, `Checkbox`, `CheckboxGroup`, `Radio`, `RadioGroup`, `SegmentedControl`, `Switch`, `Slider`, `InputGroup`, `Dropzone`, `Field`, `Label` |
157
- | Data display | `Badge`, `Avatar`, `AvatarGroup`, `Card`, `Stat`, `Table`, `Kbd` |
158
- | Feedback | `Alert`, `Toaster` + `toast`, `Progress`, `Spinner`, `Skeleton`, `EmptyState` |
159
- | Layout & nav | `Divider`, `Tabs`, `Accordion`, `AccordionItem`, `Breadcrumb`, `Pagination`, `Steps` |
160
- | App shell | `Navbar`, `Sidebar`, `Footer` |
161
- | Overlay | `Modal`, `ConfirmDialog`, `Drawer`, `Dropdown` + `MenuItem` + `MenuSeparator`, `ContextMenu`, `CommandPalette`, `Popover`, `Tooltip` |
162
- | Marketing | `HeroSection`, `FeatureGrid`, `FeatureCard`, `PricingCard`, `Testimonial`, `LogoCloud`, `CTASection`, `StatsBand` |
163
- | System | `ThemeToggle`, `LocaleProvider` |
347
+ | group | components |
348
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
349
+ | Actions | `Button`, `Link` |
350
+ | Forms | `Input`, `Textarea`, `Select`, `Combobox`, `MultiSelect`, `DatePicker`, `Calendar`, `Checkbox`, `CheckboxGroup`, `Radio`, `RadioGroup`, `SegmentedControl`, `Switch`, `Slider`, `InputGroup`, `InputAddon`, `SearchInput`, `PinInput`, `TagsInput`, `CurrencyInput`, `TimeInput`, `ColorInput`, `FileInput`, `Dropzone`, `Field`, `Label` |
351
+ | Data display | `Badge`, `Avatar`, `AvatarGroup`, `Card`, `Stat`, `Table`, `Kbd` |
352
+ | Feedback | `Alert`, `Toaster` + `toast`, `Progress`, `Spinner`, `Skeleton`, `EmptyState` |
353
+ | Layout & nav | `Divider`, `Tabs`, `Accordion`, `AccordionItem`, `Breadcrumb`, `Pagination`, `Steps` |
354
+ | App shell | `Navbar`, `Sidebar`, `Footer` |
355
+ | Overlay | `Modal`, `ConfirmDialog`, `Drawer`, `Dropdown` + `MenuItem` + `MenuSeparator`, `ContextMenu`, `CommandPalette`, `Popover`, `Tooltip` |
356
+ | Marketing | `HeroSection`, `FeatureGrid`, `FeatureCard`, `PricingCard`, `Testimonial`, `LogoCloud`, `CTASection`, `StatsBand` |
357
+ | System | `ThemeToggle`, `LocaleProvider` |
164
358
 
165
359
  Overlays are built on the native top layer and three in-house actions
166
360
  (`portal`, `focusTrap`, `anchored`) rather than a positioning dependency.
package/cli/index.mjs CHANGED
@@ -153,6 +153,39 @@ async function write(path, content, label) {
153
153
 
154
154
  /* -------------------------------------------------------------- detection -- */
155
155
 
156
+ /**
157
+ * Which kind of project this is.
158
+ *
159
+ * The library itself is plain Svelte 5 — no `$app/*`, no `$env/*`, no
160
+ * `@sveltejs/kit` anywhere in it — so a framework only decides where four
161
+ * things live:
162
+ *
163
+ * kit src/app.css src/app.html src/routes/+layout.svelte
164
+ * vite src/app.css index.html src/main.ts
165
+ * laravel resources/css/… resources/views/… the @vite directive
166
+ */
167
+ function detectFramework(deps) {
168
+ if ('@sveltejs/kit' in deps) return 'kit';
169
+ if (existsSync(join(cwd, 'artisan'))) return 'laravel';
170
+ return 'vite';
171
+ }
172
+
173
+ const FRAMEWORK_LABEL = {
174
+ kit: 'SvelteKit',
175
+ laravel: 'Laravel + Svelte',
176
+ vite: 'Svelte + Vite'
177
+ };
178
+
179
+ /** The first candidate that exists, or the first one as the place to create. */
180
+ function pickPath(candidates) {
181
+ return candidates.find((path) => existsSync(join(cwd, path))) ?? candidates[0];
182
+ }
183
+
184
+ /** The one that exists, or null — for files we patch but never invent. */
185
+ function findPath(candidates) {
186
+ return candidates.find((path) => existsSync(join(cwd, path))) ?? null;
187
+ }
188
+
156
189
  async function detectProject() {
157
190
  const pkgPath = join(cwd, 'package.json');
158
191
  if (!existsSync(pkgPath)) fail('no package.json here — run this inside your project');
@@ -160,17 +193,156 @@ async function detectProject() {
160
193
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
161
194
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
162
195
 
163
- const isKit = '@sveltejs/kit' in deps;
196
+ const framework = detectFramework(deps);
197
+ const isKit = framework === 'kit';
164
198
  const hasTailwind = '@tailwindcss/vite' in deps || 'tailwindcss' in deps;
165
199
  const hasUi = '@nqmcreative/ui' in deps;
200
+ // Kit brings its own; everywhere else the components are .svelte files in
201
+ // node_modules and something has to compile them.
202
+ const hasCompiler = isKit || '@sveltejs/vite-plugin-svelte' in deps;
166
203
 
167
204
  // Where the Tailwind entry CSS lives, or should.
168
- const candidates = isKit
169
- ? ['src/app.css', 'src/routes/+layout.css', 'src/app.postcss']
170
- : ['src/app.css', 'src/style.css', 'src/main.css'];
171
- const css = candidates.find((path) => existsSync(join(cwd, path))) ?? candidates[0];
205
+ const css = pickPath(
206
+ {
207
+ kit: ['src/app.css', 'src/routes/+layout.css', 'src/app.postcss'],
208
+ laravel: ['resources/css/app.css'],
209
+ vite: ['src/app.css', 'src/style.css', 'src/main.css']
210
+ }[framework]
211
+ );
212
+
213
+ // The document head, for the font preconnect and the no-flash theme script.
214
+ // Kit gets one written if it is missing; the other two are only patched,
215
+ // because guessing at someone's Blade layout would do more harm than a
216
+ // printed reminder.
217
+ const head = {
218
+ kit: 'src/app.html',
219
+ laravel: findPath([
220
+ 'resources/views/app.blade.php',
221
+ 'resources/views/layouts/app.blade.php',
222
+ 'resources/views/components/layouts/app.blade.php',
223
+ 'resources/views/welcome.blade.php'
224
+ ]),
225
+ vite: findPath(['index.html'])
226
+ }[framework];
227
+
228
+ // Where the entry CSS gets imported. Laravel has nowhere: the `@vite`
229
+ // directive in the Blade layout links it directly.
230
+ const entry = {
231
+ kit: 'src/routes/+layout.svelte',
232
+ laravel: null,
233
+ vite: findPath(['src/main.ts', 'src/main.js'])
234
+ }[framework];
235
+
236
+ return {
237
+ pkg,
238
+ deps,
239
+ framework,
240
+ isKit,
241
+ hasTailwind,
242
+ hasUi,
243
+ hasCompiler,
244
+ css: join(cwd, css),
245
+ cssRel: css,
246
+ head,
247
+ entry
248
+ };
249
+ }
172
250
 
173
- return { pkg, deps, isKit, hasTailwind, hasUi, css: join(cwd, css), cssRel: css };
251
+ /* ------------------------------------------------------------------ head -- */
252
+
253
+ /** Escapes a literal for use inside a RegExp — the markers contain `%` and `/`. */
254
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
255
+
256
+ /** The two things that have to run before first paint, in document order. */
257
+ function headAdditions(style, html) {
258
+ const additions = [];
259
+ if (style.fonts && !html.includes('fonts.gstatic.com')) {
260
+ additions.push(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />`);
261
+ }
262
+ if (!html.includes('nqm-theme')) {
263
+ additions.push(
264
+ `<script>`,
265
+ `\tconst saved = localStorage.getItem('nqm-theme');`,
266
+ `\tif (saved === 'dark' || saved === 'light') document.documentElement.classList.add(saved);`,
267
+ `</script>`
268
+ );
269
+ }
270
+ return additions;
271
+ }
272
+
273
+ /**
274
+ * Puts them in the document head, wherever that is for this framework.
275
+ *
276
+ * Kit renders into a `%sveltekit.head%` placeholder; a Vite index.html and a
277
+ * Blade layout both have a real `</head>` to sit above. When there is no file
278
+ * to patch — or no marker in it — the lines are printed instead of guessed at.
279
+ */
280
+ async function wireHead(project, style) {
281
+ const { framework, head } = project;
282
+
283
+ const printByHand = (additions, reason) => {
284
+ log();
285
+ warn(reason);
286
+ log(additions.map((line) => c.cyan(` ${line}`)).join('\n'));
287
+ };
288
+
289
+ if (!head) {
290
+ printByHand(
291
+ headAdditions(style, ''),
292
+ framework === 'laravel'
293
+ ? 'no Blade layout found — put these inside <head> in yours:'
294
+ : 'no index.html found — put these inside its <head>:'
295
+ );
296
+ return;
297
+ }
298
+
299
+ const htmlPath = join(cwd, head);
300
+ let html = await readOr(htmlPath);
301
+
302
+ if (!html && framework === 'kit') {
303
+ // Some `sv create` templates omit it and let SvelteKit fall back to a
304
+ // built-in. Write the standard one so there is somewhere to put the
305
+ // preconnect and the no-flash theme script.
306
+ html = [
307
+ '<!doctype html>',
308
+ '<html lang="en">',
309
+ ' <head>',
310
+ ' <meta charset="utf-8" />',
311
+ ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
312
+ ' %sveltekit.head%',
313
+ ' </head>',
314
+ ' <body data-sveltekit-preload-data="hover">',
315
+ ' <div style="display: contents">%sveltekit.body%</div>',
316
+ ' </body>',
317
+ '</html>',
318
+ ''
319
+ ].join('\n');
320
+ skip('src/app.html was missing — creating it');
321
+ }
322
+
323
+ const additions = headAdditions(style, html);
324
+ if (additions.length === 0) {
325
+ skip(`${head} already wired`);
326
+ return;
327
+ }
328
+
329
+ const marker = framework === 'kit' ? '%sveltekit.head%' : '</head>';
330
+ if (!html.includes(marker)) {
331
+ printByHand(additions, `${head} has no ${marker} — add these by hand, inside <head>:`);
332
+ return;
333
+ }
334
+
335
+ // Matched with its own indentation so the block lands at the right depth,
336
+ // and replaced through a function so a `$` in the lines stays literal.
337
+ html = html.replace(new RegExp(`([ \\t]*)${escapeRe(marker)}`), (_match, indent) => {
338
+ // The placeholder is itself a child of <head>; a closing tag is not, so
339
+ // its children sit one level deeper — in whichever unit the file uses.
340
+ const unit = indent.startsWith('\t') ? '\t' : ' ';
341
+ const inner = framework === 'kit' ? indent : `${indent}${unit}`;
342
+ const body = additions.map((line) => line.replace(/^\t/, unit));
343
+ return `${inner}${body.join(`\n${inner}`)}\n${indent}${marker}`;
344
+ });
345
+ await write(htmlPath, html, head);
174
346
  }
175
347
 
176
348
  /** How many `../` from the CSS file up to node_modules. */
@@ -188,7 +360,7 @@ async function init() {
188
360
 
189
361
  log();
190
362
  log(c.bold(` @nqmcreative/ui — ${style.title}`));
191
- log(c.dim(` ${project.isKit ? 'SvelteKit' : 'Svelte + Vite'} project detected`));
363
+ log(c.dim(` ${FRAMEWORK_LABEL[project.framework]} project detected`));
192
364
  log();
193
365
 
194
366
  if (!project.hasUi) {
@@ -203,6 +375,13 @@ async function init() {
203
375
  log();
204
376
  }
205
377
 
378
+ if (!project.hasCompiler) {
379
+ // The package ships .svelte files, so the app compiles them itself.
380
+ warn('nothing here compiles Svelte components:');
381
+ log(c.cyan(' bun add -d svelte @sveltejs/vite-plugin-svelte'));
382
+ log();
383
+ }
384
+
206
385
  /* --- 1. the entry CSS --- */
207
386
  const source = sourcePath(project.cssRel);
208
387
  const lines = [
@@ -245,85 +424,62 @@ async function init() {
245
424
  await write(project.css, body, project.cssRel);
246
425
  }
247
426
 
248
- /* --- 2. app.html: font preconnect + no-flash theme --- */
249
- if (project.isKit) {
250
- const htmlPath = join(cwd, 'src/app.html');
251
- let html = await readOr(htmlPath);
252
- if (!html) {
253
- // Some `sv create` templates omit it and let SvelteKit fall back to a
254
- // built-in. Write the standard one so there is somewhere to put the
255
- // preconnect and the no-flash theme script.
256
- html = [
257
- '<!doctype html>',
258
- '<html lang="en">',
259
- ' <head>',
260
- ' <meta charset="utf-8" />',
261
- ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
262
- ' %sveltekit.head%',
263
- ' </head>',
264
- ' <body data-sveltekit-preload-data="hover">',
265
- ' <div style="display: contents">%sveltekit.body%</div>',
266
- ' </body>',
267
- '</html>',
268
- ''
269
- ].join('\n');
270
- skip('src/app.html was missing — creating it');
271
- }
272
- {
273
- // Unindented — `%sveltekit.head%` already sits at its own indentation,
274
- // so the join below supplies it and the first line does not double up.
275
- const additions = [];
276
- if (style.fonts && !html.includes('fonts.gstatic.com')) {
277
- additions.push(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />`);
278
- }
279
- if (!html.includes('nqm-theme')) {
280
- additions.push(
281
- `<script>`,
282
- `\tconst saved = localStorage.getItem('nqm-theme');`,
283
- `\tif (saved === 'dark' || saved === 'light') document.documentElement.classList.add(saved);`,
284
- `</script>`
285
- );
286
- }
287
- if (additions.length === 0) {
288
- skip('src/app.html already wired');
289
- } else if (!html.includes('%sveltekit.head%')) {
290
- warn('src/app.html has no %sveltekit.head% — add these by hand:');
291
- log(additions.map((line) => `\t\t${line}`).join('\n'));
292
- } else {
293
- const indent = /([ \t]*)%sveltekit\.head%/.exec(html)?.[1] ?? '\t\t';
294
- html = html.replace(
295
- '%sveltekit.head%',
296
- `${additions.join(`\n${indent}`)}\n${indent}%sveltekit.head%`
297
- );
298
- await write(htmlPath, html, 'src/app.html');
299
- }
300
- }
427
+ // Laravel keeps its markup outside resources/css, so automatic detection
428
+ // never reaches it. These are the project's own lines, not ours — printed
429
+ // rather than written, so removing them stays possible.
430
+ if (project.framework === 'laravel' && !/@source ['"]\.\.\/(js|views)/.test(body)) {
431
+ log();
432
+ warn(`Tailwind also has to see your own markup add to ${project.cssRel}:`);
433
+ log(c.cyan(` @source '../js';`));
434
+ log(c.cyan(` @source '../views';`));
301
435
  }
302
436
 
437
+ /* --- 2. the document head: font preconnect + no-flash theme --- */
438
+ await wireHead(project, style);
439
+
303
440
  /* --- 3. remind about the vite plugin, which we will not rewrite --- */
304
441
  const viteConfig =
305
- ['vite.config.ts', 'vite.config.js'].map((f) => join(cwd, f)).find((f) => existsSync(f)) ??
306
- null;
442
+ ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']
443
+ .map((f) => join(cwd, f))
444
+ .find((f) => existsSync(f)) ?? null;
307
445
  const viteSource = viteConfig ? await readOr(viteConfig) : '';
308
446
  if (!viteSource.includes('@tailwindcss/vite')) {
447
+ const plugins = {
448
+ kit: `plugins: [tailwindcss(), sveltekit()]`,
449
+ laravel: `plugins: [laravel({ input: ['${project.cssRel}', 'resources/js/app.js'] }), tailwindcss(), svelte()]`,
450
+ vite: `plugins: [tailwindcss(), svelte()]`
451
+ }[project.framework];
452
+ const after = project.framework === 'kit' ? 'sveltekit()' : 'svelte()';
453
+
309
454
  log();
310
455
  warn(
311
- `add the Tailwind plugin to ${viteConfig ? relative(cwd, viteConfig) : 'your vite config'} — before sveltekit():`
456
+ `add the Tailwind plugin to ${viteConfig ? relative(cwd, viteConfig) : 'your vite config'} — before ${after}:`
312
457
  );
313
458
  log(c.cyan(` import tailwindcss from '@tailwindcss/vite';`));
314
- log(c.cyan(` plugins: [tailwindcss(), sveltekit()]`));
459
+ log(c.cyan(` ${plugins}`));
315
460
  } else {
316
461
  skip('vite config already has the Tailwind plugin');
317
462
  }
318
463
 
319
464
  /* --- 4. import the CSS once --- */
320
- if (project.isKit) {
321
- const layoutPath = join(cwd, 'src/routes/+layout.svelte');
465
+ if (project.framework === 'laravel') {
466
+ // Nothing imports it: the Blade layout hands the path to Vite, which is
467
+ // also what makes `php artisan serve` pick up the built file.
468
+ const blade = project.head ? await readOr(join(cwd, project.head)) : '';
469
+ if (blade.includes(project.cssRel)) {
470
+ skip(`${project.head} already loads ${project.cssRel}`);
471
+ } else {
472
+ log();
473
+ warn(`load the CSS from your Blade layout, inside <head>:`);
474
+ log(c.cyan(` @vite(['${project.cssRel}', 'resources/js/app.js'])`));
475
+ }
476
+ } else if (project.framework === 'kit') {
477
+ const layoutPath = join(cwd, project.entry);
322
478
  let layout = await readOr(layoutPath);
323
479
  const line = `import '../app.css';`;
324
480
 
325
481
  if (layout.includes('app.css')) {
326
- skip('src/routes/+layout.svelte already imports the CSS');
482
+ skip(`${project.entry} already imports the CSS`);
327
483
  } else if (!layout.trim()) {
328
484
  await write(
329
485
  layoutPath,
@@ -335,16 +491,37 @@ async function init() {
335
491
 
336
492
  {@render children()}
337
493
  `,
338
- 'src/routes/+layout.svelte'
494
+ project.entry
339
495
  );
340
496
  } else if (/<script[^>]*>/.test(layout)) {
341
497
  layout = layout.replace(/(<script[^>]*>\n)/, `$1\t${line}\n`);
342
- await write(layoutPath, layout, 'src/routes/+layout.svelte');
498
+ await write(layoutPath, layout, project.entry);
343
499
  } else {
344
500
  log();
345
- warn('import the CSS once, at the top of src/routes/+layout.svelte:');
501
+ warn(`import the CSS once, at the top of ${project.entry}:`);
346
502
  log(c.cyan(` ${line}`));
347
503
  }
504
+ } else {
505
+ // Plain Vite: the entry module is the one thing every page loads.
506
+ const rel = project.entry
507
+ ? relative(dirname(join(cwd, project.entry)), project.css)
508
+ .split(/[\\/]/)
509
+ .join('/')
510
+ : null;
511
+ const line = rel ? `import '${rel.startsWith('.') ? rel : `./${rel}`}';` : null;
512
+
513
+ if (!project.entry) {
514
+ log();
515
+ warn(`import ${project.cssRel} once, at the top of your entry module:`);
516
+ log(c.cyan(` import './${project.cssRel.split('/').pop()}';`));
517
+ } else {
518
+ const source = await readOr(join(cwd, project.entry));
519
+ if (source.includes(line) || /import ['"][^'"]*app\.css['"]/.test(source)) {
520
+ skip(`${project.entry} already imports the CSS`);
521
+ } else {
522
+ await write(join(cwd, project.entry), `${line}\n${source}`, project.entry);
523
+ }
524
+ }
348
525
  }
349
526
 
350
527
  log();
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Hex colours for the colour field.
3
+ *
4
+ * Only hex: it is the one notation `<input type="color">` accepts and the one
5
+ * every design tool copies to the clipboard. Anything else the app can convert
6
+ * before it binds.
7
+ */
8
+ /**
9
+ * `abc`, `#ABC`, `#aabbcc` → `#aabbcc`. Returns `null` when it is not a hex
10
+ * colour, so a half-typed value can be left alone rather than corrected under
11
+ * the caret.
12
+ *
13
+ * Shorthand is expanded and alpha is dropped: the native swatch understands
14
+ * neither, and a value the swatch cannot show is worse than a rounded one.
15
+ */
16
+ export declare function normaliseHex(text: string): string | null;
17
+ /**
18
+ * True when a colour is dark enough to want light text on top of it.
19
+ *
20
+ * Rec. 601 luma rather than WCAG relative luminance — the threshold is for
21
+ * picking one of two label colours on a swatch, not for proving a contrast
22
+ * ratio, and this is the version everyone's design tokens are tuned against.
23
+ */
24
+ export declare function isDark(hex: string): boolean;