create-lupine 1.0.13 → 1.0.15
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/index.js +32 -2
- package/package.json +1 -1
- package/templates/common/AI_CONTEXT.md +245 -50
- package/templates/common/dev/cp-folder.js +2 -6
- package/templates/cv-starter/web/src/index.html +1 -1
- package/templates/doc-starter/web/src/index.html +1 -1
- package/templates/hello-world/web/src/index.html +1 -1
- package/templates/hello-world/web/src/index.tsx +54 -15
- package/templates/hello-world/web/src/styles/global.css +63 -0
- package/templates/responsive-starter/api/package.json +6 -0
- package/templates/responsive-starter/api/resources/config_default.json +6 -0
- package/templates/responsive-starter/api/resources/install.sqlite.sql +4 -0
- package/templates/responsive-starter/api/src/index.ts +4 -0
- package/templates/responsive-starter/api/src/service/root-api.ts +18 -0
- package/templates/responsive-starter/lupine.json +23 -0
- package/templates/responsive-starter/web/assets/favicon.ico +0 -0
- package/templates/responsive-starter/web/package.json +6 -0
- package/templates/responsive-starter/web/src/app-icons.ts +72 -0
- package/templates/responsive-starter/web/src/components/input-history-component.tsx +93 -0
- package/templates/responsive-starter/web/src/components/mine-about-page.tsx +86 -0
- package/templates/responsive-starter/web/src/components/mine-premium-page.tsx +80 -0
- package/templates/responsive-starter/web/src/components/mine-settings-page.tsx +219 -0
- package/templates/responsive-starter/web/src/components/mobile-top-search-menu.tsx +119 -0
- package/templates/responsive-starter/web/src/components/note-detail.tsx +116 -0
- package/templates/responsive-starter/web/src/components/note-edit.tsx +154 -0
- package/templates/responsive-starter/web/src/components/note-search-page.tsx +193 -0
- package/templates/responsive-starter/web/src/components/search-input.tsx +95 -0
- package/templates/responsive-starter/web/src/components/side-menu-content.tsx +178 -0
- package/templates/responsive-starter/web/src/frames/app-responsive-frame.tsx +46 -0
- package/templates/responsive-starter/web/src/index.html +16 -0
- package/templates/responsive-starter/web/src/index.tsx +42 -0
- package/templates/responsive-starter/web/src/pages/about-page.tsx +43 -0
- package/templates/responsive-starter/web/src/pages/finance-page.tsx +46 -0
- package/templates/responsive-starter/web/src/pages/home-page.tsx +452 -0
- package/templates/responsive-starter/web/src/pages/mine-page.tsx +325 -0
- package/templates/responsive-starter/web/src/pages/tools-page.tsx +46 -0
- package/templates/responsive-starter/web/src/services/local-notes-service.ts +87 -0
- package/templates/responsive-starter/web/src/services/mine-service.ts +45 -0
- package/templates/responsive-starter/web/src/styles/app.css +0 -0
- package/templates/responsive-starter/web/src/styles/base-css.ts +65 -0
- package/templates/responsive-starter/web/src/styles/global.css +2143 -0
- package/templates/responsive-starter/web/src/styles/theme.ts +16 -0
package/index.js
CHANGED
|
@@ -72,6 +72,22 @@ const TEMPLATES = [
|
|
|
72
72
|
color: green,
|
|
73
73
|
needsPress: true,
|
|
74
74
|
},
|
|
75
|
+
{
|
|
76
|
+
name: 'responsive-starter-tabs',
|
|
77
|
+
dir: 'responsive-starter',
|
|
78
|
+
layout: 'tabs',
|
|
79
|
+
display: 'Responsive Starter (tabs)',
|
|
80
|
+
itemType: 'frontend',
|
|
81
|
+
color: green,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'responsive-starter-sidemenu',
|
|
85
|
+
dir: 'responsive-starter',
|
|
86
|
+
layout: 'sidemenu',
|
|
87
|
+
display: 'Responsive Starter (sidemenu)',
|
|
88
|
+
itemType: 'frontend',
|
|
89
|
+
color: green,
|
|
90
|
+
},
|
|
75
91
|
];
|
|
76
92
|
|
|
77
93
|
async function init() {
|
|
@@ -136,7 +152,10 @@ async function init() {
|
|
|
136
152
|
template = selected.name;
|
|
137
153
|
}
|
|
138
154
|
|
|
139
|
-
const
|
|
155
|
+
const templateObj = TEMPLATES.find((t) => t.name === template);
|
|
156
|
+
const templateDirName = templateObj?.dir || template;
|
|
157
|
+
|
|
158
|
+
const templateDir = path.resolve(fileURLToPath(import.meta.url), '../templates', templateDirName);
|
|
140
159
|
const commonDir = path.resolve(fileURLToPath(import.meta.url), '../templates', 'common');
|
|
141
160
|
|
|
142
161
|
// ... (copy logic remains same)
|
|
@@ -157,6 +176,18 @@ async function init() {
|
|
|
157
176
|
|
|
158
177
|
copyDir(templateDir, targetAppDir);
|
|
159
178
|
|
|
179
|
+
if (templateObj?.layout) {
|
|
180
|
+
const frameFile = path.join(targetAppDir, 'web/src/frames/app-responsive-frame.tsx');
|
|
181
|
+
if (fs.existsSync(frameFile)) {
|
|
182
|
+
let content = fs.readFileSync(frameFile, 'utf-8');
|
|
183
|
+
content = content.replace(
|
|
184
|
+
/const DEFAULT_LAYOUT = '.*';/,
|
|
185
|
+
`const DEFAULT_LAYOUT = '${templateObj.layout}';`
|
|
186
|
+
);
|
|
187
|
+
fs.writeFileSync(frameFile, content);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
160
191
|
changePkgName(path.join(targetAppDir, 'lupine.json'), appName);
|
|
161
192
|
changePkgName(path.join(targetAppDir, 'api', 'package.json'), appName + '-api');
|
|
162
193
|
changePkgName(path.join(targetAppDir, 'web', 'package.json'), appName + '-web');
|
|
@@ -209,7 +240,6 @@ async function init() {
|
|
|
209
240
|
'lupine.web': latestWeb,
|
|
210
241
|
};
|
|
211
242
|
|
|
212
|
-
const templateObj = TEMPLATES.find((t) => t.name === template);
|
|
213
243
|
if (templateObj && templateObj.needsPress) {
|
|
214
244
|
pkg.dependencies['lupine.press'] = latestPress;
|
|
215
245
|
pkg.devDependencies['gray-matter'] = '^4.0.3';
|
package/package.json
CHANGED
|
@@ -4,20 +4,30 @@
|
|
|
4
4
|
|
|
5
5
|
**🛑 CRITICAL WARNINGS 🛑**
|
|
6
6
|
|
|
7
|
-
1.
|
|
8
|
-
2. **NO VIRTUAL DOM STATE**:
|
|
7
|
+
1. **`useState` EXISTS but rerenders the whole component**: Use it for simple/small components. For complex or large components, prefer `HtmlVar` for surgical, partial updates.
|
|
8
|
+
2. **NO VIRTUAL DOM STATE by default**: Without `useState`, changing a variable DOES NOT re-render the component. You must manually update `HtmlVar.value`.
|
|
9
9
|
3. **NO CONTROLLED INPUTS**: Do not bind `value={state}`. Read values from DOM on submit.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
## 1. Core Philosophy & Reactivity
|
|
14
14
|
|
|
15
|
-
- **`
|
|
15
|
+
- **`useState` — React-style local state (small/simple components)**:
|
|
16
|
+
- Import: `import { useState } from 'lupine.components';`
|
|
17
|
+
- Syntax: `const [value, setValue] = useState(initial);` — calling `setValue(...)` rerenders the **entire** component.
|
|
18
|
+
- ✅ **Use when**: The component is small, state drives most of the UI, and the React-style patterns feel natural.
|
|
19
|
+
- ⚠️ **Avoid when**: The component is large/complex, or only a tiny portion of the UI needs to change (e.g. a progress counter, a list inside a page) — repeated full rerenders are wasteful.
|
|
20
|
+
- **`ref.onLoad` + useState**: `onLoad` is called **only on initial mount** (not on rerenders). It's the right place for async data fetch that populates state.
|
|
21
|
+
|
|
22
|
+
- **`HtmlVar` — Surgical partial updates (large/complex components)**:
|
|
16
23
|
- Use `HtmlVar` to wrap dynamic sections (lists, conditional renderings, async content).
|
|
17
|
-
- **Pattern**: `const dom = new HtmlVar(initialContent);`
|
|
24
|
+
- **Pattern**: `const dom = new HtmlVar(initialContent);` → JSX `{dom.node}` → `dom.value = updatedContent`.
|
|
25
|
+
- ✅ **Use when**: Only a small part of a large component changes (e.g. list inside a page, progress text), or state is updated by external hooks (`props.hook.onProgress`), or high-frequency updates (file upload progress).
|
|
26
|
+
- The rest of the component DOM is never touched — highly efficient.
|
|
27
|
+
|
|
18
28
|
- **Direct DOM Access**:
|
|
19
29
|
- Use `RefProps` to get reference to the component root.
|
|
20
|
-
- Use `ref.$(selector)` to find elements (inputs, containers).
|
|
30
|
+
- Use `ref.$(selector)` to find the first element, `ref.$all(selector)` to find all elements (inputs, containers).
|
|
21
31
|
- **Value Retrieval**: `const val = ref.$('input.my-class').value`.
|
|
22
32
|
|
|
23
33
|
## 2. Key Interfaces
|
|
@@ -46,13 +56,28 @@ Supports nesting and media queries. **Prefer this over inline styles.** Define y
|
|
|
46
56
|
|
|
47
57
|
## 3. Styles & Themes ("The Look")
|
|
48
58
|
|
|
49
|
-
### Global Variables (Theming)
|
|
50
|
-
|
|
51
|
-
**NEVER hardcode colors** (e.g., `#000`). Always use CSS variables to support Dark/Light modes.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
### Global Variables (Theming) & Dark Mode Compatibility
|
|
60
|
+
|
|
61
|
+
**NEVER hardcode colors** (e.g., `#000`, `#fff`, `#f0f0f0`). Always use CSS variables to support Dark/Light modes. If you must use a fallback, wrap it: `var(--primary-bg-color, #fff)`.
|
|
62
|
+
|
|
63
|
+
#### 🎨 Color Variable Semantics (CRITICAL FOR DARK MODE)
|
|
64
|
+
|
|
65
|
+
1. **Backgrounds (`--primary-bg-color` vs `--secondary-bg-color`)**:
|
|
66
|
+
- `--primary-bg-color`: The lowest-level background (White in light mode, **Deep Black** in dark mode).
|
|
67
|
+
- `--secondary-bg-color`: An elevated background (Light gray in light mode, **Lighter Black/Gray** in dark mode).
|
|
68
|
+
- _⚠️ Dark Mode Trap_: If you place a floating panel/card on the main body, **do NOT** use `--primary-bg-color` for the panel. It will blend into the body's deep black and become invisible. Use `--secondary-bg-color` for elevated panels to ensure visual separation.
|
|
69
|
+
2. **Text Colors (`--primary-color` vs `--secondary-color`)**:
|
|
70
|
+
- `--primary-color`: The primary **TEXT** color. (Dark grey/black in light mode, **White** in dark mode).
|
|
71
|
+
- _⚠️ Dark Mode Trap_: **Never** use `--primary-color` as the background color for a blue "Primary Action Button". It will turn white in dark mode.
|
|
72
|
+
- Always explicitly declare `color: 'var(--primary-color, inherit)'` on cards/containers so child text properly flips white in dark mode.
|
|
73
|
+
3. **Action / Brand Colors (`--primary-accent-color`)**:
|
|
74
|
+
- `--primary-accent-color`: The vibrant brand color (e.g., Lupine Blue). Use this for the **backgrounds of primary buttons**, active tabs, slider fills, and highlights.
|
|
75
|
+
- When using this as a background, set the text color to `var(--primary-bg-color)` so it stays high-contrast (white) in both themes.
|
|
76
|
+
4. **Borders (`--primary-border` / `--secondary-border-color`)**:
|
|
77
|
+
- Replace all hardcoded `#eee`, `#ccc`, `#999` borders with these to ensure they darken appropriately in dark mode.
|
|
78
|
+
5. **Status Colors**:
|
|
79
|
+
- `--success-color`, `--warning-color`, `--error-color`, `--success-bg-color` (Use replacing hardcoded green/reds).
|
|
80
|
+
6. **Spacing**: `var(--space-m)` (8px), `var(--space-l)` (16px).
|
|
56
81
|
|
|
57
82
|
### Standard Utility Classes
|
|
58
83
|
|
|
@@ -60,7 +85,7 @@ Supports nesting and media queries. **Prefer this over inline styles.** Define y
|
|
|
60
85
|
- **Margins/Padding**: `m-auto`, `p-m`, `mt-s`, `pb-l` (s=small, m=medium, l=large).
|
|
61
86
|
- **Text**: `.text-center`, `.ellipsis`.
|
|
62
87
|
|
|
63
|
-
### The Component CSS & Ampersand (`&`) Pattern
|
|
88
|
+
### The Component CSS & Ampersand (`&`) Pattern (must go with RefProps)
|
|
64
89
|
|
|
65
90
|
Lupine.js handles component-scoped CSS safely to avoid class collisions. The modern and **preferred** way to style components is to attach a `css={css}` prop to the root element and use the **Ampersand (`&`) Pattern**.
|
|
66
91
|
|
|
@@ -71,7 +96,7 @@ export const MyComponent = () => {
|
|
|
71
96
|
const ref: RefProps = {
|
|
72
97
|
onLoad: async () => {
|
|
73
98
|
// 3. Querying namespaced elements
|
|
74
|
-
const btn = ref.$('
|
|
99
|
+
const btn = ref.$('.&-btn');
|
|
75
100
|
btn.innerHTML = 'Ready';
|
|
76
101
|
},
|
|
77
102
|
};
|
|
@@ -101,11 +126,16 @@ export const MyComponent = () => {
|
|
|
101
126
|
};
|
|
102
127
|
```
|
|
103
128
|
|
|
104
|
-
**Key Takeaways for
|
|
129
|
+
**Key Takeaways for `&` and `RefProps`**:
|
|
105
130
|
|
|
106
|
-
1. **In `CssProps
|
|
131
|
+
1. **In `CssProps` Binding**: The top-level keys in `CssProps` (like `display: 'flex'`) apply **directly** to the root element (the one attached to `ref={ref}`). **Do not** wrap your root styles in an artificial `.&-container`.
|
|
132
|
+
- `'&.active'` applies to the root element when it has the `.active` native class.
|
|
133
|
+
- `'.&-item'` applies to _descendant_ elements that have `class="&-item"`.
|
|
107
134
|
2. **In JSX `class` attributes**: Add `class="&-item"`. You can still mix native classes: `class="row-box &-item"`.
|
|
108
|
-
3. **In
|
|
135
|
+
3. **In DOM Queries**:
|
|
136
|
+
- **🚨 NEVER use `document.querySelector('.&-item')` or `element.querySelector('.&-item')`**. Standard browser APIs DO NOT understand the `&` symbol and will fail to find the element.
|
|
137
|
+
- Use **`ref.$('.&-item')`** (WITH leading dot) to get the first matching element. The underlying logic simply replaces `&` with the generated ID (e.g. `l1234`), so this correctly translates to querying `.l1234 .l1234-item` which safely finds descendants within the current component's isolated namespace.
|
|
138
|
+
- Use **`ref.$all('.&-item')`** to get a `NodeList` of all matching descendants within the component.
|
|
109
139
|
|
|
110
140
|
## 4. CSS Placement Strategies: `css={}` vs `bindGlobalStyle`
|
|
111
141
|
|
|
@@ -122,12 +152,9 @@ When you pass `css={css}` to a JSX element, Lupine automatically evaluates it an
|
|
|
122
152
|
|
|
123
153
|
```typescript
|
|
124
154
|
export const MyUniquePage = () => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
...
|
|
129
|
-
</div>
|
|
130
|
-
);
|
|
155
|
+
// Styles apply directly to the root element
|
|
156
|
+
const css = { padding: '10px' };
|
|
157
|
+
return <div css={css}>...</div>;
|
|
131
158
|
};
|
|
132
159
|
```
|
|
133
160
|
|
|
@@ -139,7 +166,7 @@ export const MyUniquePage = () => {
|
|
|
139
166
|
|
|
140
167
|
**How it works seamlessly with `&`**:
|
|
141
168
|
|
|
142
|
-
1. You generate a unique ID based on the `CssProps` content (this ID remains identical for every instance of the component): `const globalCssId = getGlobalStylesId(css)
|
|
169
|
+
1. You generate a unique ID based on the `CssProps` content (this ID remains identical for every instance of the component): `const globalCssId = getGlobalStylesId(css);`. **CRITICAL**: This MUST be called _inside_ the component function body, not at the module root level, because it depends on the rendering context (themes, SSR scope) being fully initialized.
|
|
143
170
|
2. You bind the style block globally once: `bindGlobalStyle(globalCssId, css);`
|
|
144
171
|
3. You assign this ID to the component's `ref` so Lupine knows what to replace `&` with: `const ref: RefProps = { globalCssId };`
|
|
145
172
|
4. Use `class="&-item"` normally. Lupine replaces `&` with the identical `globalCssId` across all instances!
|
|
@@ -150,38 +177,69 @@ export const MyUniquePage = () => {
|
|
|
150
177
|
```typescript
|
|
151
178
|
import { bindGlobalStyle, getGlobalStylesId, CssProps, RefProps } from 'lupine.web';
|
|
152
179
|
|
|
153
|
-
export const ToggleButton = (props: { color?: string }) => {
|
|
180
|
+
export const ToggleButton = (props: { color?: string; disabled?: boolean }) => {
|
|
154
181
|
const css: CssProps = {
|
|
155
|
-
// 1.
|
|
156
|
-
'.&-container'
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
182
|
+
// 1. Top-level properties apply directly to the root element.
|
|
183
|
+
// Do NOT wrap these in '.&-container'
|
|
184
|
+
padding: '10px',
|
|
185
|
+
color: 'var(--primary-color)',
|
|
186
|
+
|
|
187
|
+
// 2. You can mix nested modifiers for the root element:
|
|
188
|
+
'&.disabled': { opacity: '0.5' },
|
|
189
|
+
|
|
190
|
+
// 3. Or target specific inner children:
|
|
191
|
+
'.&-inner': { fontWeight: 'bold' },
|
|
160
192
|
};
|
|
161
193
|
|
|
162
|
-
//
|
|
194
|
+
// 4. Generate the ID and bind it globally (Call INSIDE the component!)
|
|
163
195
|
const tabGlobalCssId = getGlobalStylesId(css);
|
|
164
196
|
bindGlobalStyle(tabGlobalCssId, css);
|
|
165
197
|
|
|
166
|
-
//
|
|
198
|
+
// 5. Assign the global ID to the reference
|
|
167
199
|
const ref: RefProps = {
|
|
168
200
|
globalCssId: tabGlobalCssId,
|
|
169
201
|
};
|
|
170
202
|
|
|
171
203
|
return (
|
|
172
|
-
<div
|
|
173
|
-
|
|
174
|
-
class='&-container'
|
|
175
|
-
ref={ref}
|
|
176
|
-
// Handle instance-specific differences with inline style!
|
|
177
|
-
style={{ backgroundColor: props.color }}
|
|
178
|
-
>
|
|
179
|
-
Click Me
|
|
204
|
+
<div ref={ref} class={props.disabled ? 'disabled' : ''} style={{ backgroundColor: props.color }}>
|
|
205
|
+
<span class='&-inner'>Click Me</span>
|
|
180
206
|
</div>
|
|
181
207
|
);
|
|
182
208
|
};
|
|
183
209
|
```
|
|
184
210
|
|
|
211
|
+
### ⚠️ IMPORTANT: Hardcoded Namespace IDs vs `getGlobalStylesId`
|
|
212
|
+
|
|
213
|
+
While using `getGlobalStylesId` combined with the `&` pattern is the most robust approach, you may sometimes use a hardcoded string as the namespace ID: `bindGlobalStyle('my-custom-component', css)`.
|
|
214
|
+
|
|
215
|
+
If you do this, Lupine will generate CSS targeting `.my-custom-component`. Therefore, **you MUST ensure that your root JSX element explicitly includes this exact class name**, otherwise the top-level CSS properties will fail to apply.
|
|
216
|
+
|
|
217
|
+
**CORRECT Example (Hardcoded ID):**
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
const css: CssProps = { padding: '10px' };
|
|
221
|
+
// ID is 'my-custom-component'
|
|
222
|
+
bindGlobalStyle('my-custom-component', css);
|
|
223
|
+
|
|
224
|
+
// Root element MUST have class='my-custom-component'
|
|
225
|
+
return <div class='my-custom-component'>...</div>;
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### ⚠️ IMPORTANT: The "Static `CssProps`" Rule
|
|
229
|
+
|
|
230
|
+
Because `bindGlobalStyle(getGlobalStylesId(css), css)` injects your `<style>` tags into the `<head>` of the document **globally**, your `CssProps` definition **MUST** be entirely static and shared across all instances of a component.
|
|
231
|
+
|
|
232
|
+
**ANTI-PATTERN:** Putting React variables (like `isVertical`, `size`, `color`) directly inside the `CssProps` object structure.
|
|
233
|
+
|
|
234
|
+
- **Why it's bad:** Every time the component re-renders with a different prop, `getGlobalStylesId` calculates a brand new hash. `bindGlobalStyle` then injects a duplicate `<style>` block into the head. If a user places two `Slider`s on the same page (one vertical, one horizontal), they will generate conflicting CSS IDs and the latter will aggressively overwrite the former's styles, corrupting the layout.
|
|
235
|
+
|
|
236
|
+
**CORRECT PATTERN:** Define one immutable `const css: CssProps = {...}` outside of any reactive dependencies.
|
|
237
|
+
|
|
238
|
+
- Handle visual variations by appending standard class names to your root element (`class={isVertical ? 'vertical' : 'horizontal'}`).
|
|
239
|
+
- In your static CSS definition, target these root modifier classes by prefixing the namespace `&` with the modifier: `&.vertical .&-track` or `&.horizontal .&-fill`.
|
|
240
|
+
- If you need precise, dynamic position coordinates (e.g. mouse tracking, progress percentages), calculate them in your JavaScript hook and inject them into standard DOM elements using `el.style.setProperty('--my-var', val)` or direct assignment, while your CSS relies purely on standard layout rules.
|
|
241
|
+
- Since css supports nested selectors (CSS-in-JS), the efficient approach is to define all CSS in one css prop on the top-level/root tag of a component, using class names on children. Avoid spreading inline css props across child elements.
|
|
242
|
+
|
|
185
243
|
---
|
|
186
244
|
|
|
187
245
|
## 5. Common Patterns ("The Lupine Way")
|
|
@@ -213,7 +271,7 @@ const MyPage = () => {
|
|
|
213
271
|
// 4. Events
|
|
214
272
|
const onSearch = async () => {
|
|
215
273
|
// Read directly from DOM
|
|
216
|
-
const query = ref.$('input
|
|
274
|
+
const query = ref.$('input.&-search').value;
|
|
217
275
|
// Update logic var
|
|
218
276
|
pageIndex = 0;
|
|
219
277
|
// Update UI manually
|
|
@@ -228,7 +286,7 @@ const MyPage = () => {
|
|
|
228
286
|
|
|
229
287
|
return (
|
|
230
288
|
<div ref={ref}>
|
|
231
|
-
<input class='search' />
|
|
289
|
+
<input class='&-search' />
|
|
232
290
|
<button onClick={onSearch}>Go</button>
|
|
233
291
|
{/* Embed Dynamic Content */}
|
|
234
292
|
{listDom.node}
|
|
@@ -239,33 +297,47 @@ const MyPage = () => {
|
|
|
239
297
|
|
|
240
298
|
### Mobile Navigation (`SliderFrame`)
|
|
241
299
|
|
|
242
|
-
Lupine uses a "Slide-over" model for navigation (Drill-down).
|
|
300
|
+
Lupine uses a "Slide-over" model for navigation (Drill-down). To achieve infinite nesting (where a child page can open a grandchild page), each Component level simply needs to define its own `sliderHook` and its own `<SliderFrame>` tag to act as the placeholder for its children.
|
|
243
301
|
|
|
244
302
|
```typescript
|
|
245
303
|
import { SliderFrame, SliderFrameHookProps, HeaderWithBackFrame } from 'lupine.components';
|
|
246
304
|
|
|
247
|
-
// Parent Component
|
|
305
|
+
// 1. Parent Component (or Level 1)
|
|
248
306
|
const Parent = () => {
|
|
307
|
+
// Define hook for Level 2
|
|
249
308
|
const sliderHook: SliderFrameHookProps = {};
|
|
250
309
|
|
|
251
310
|
const openDetail = (id) => {
|
|
252
311
|
// Push new view onto stack
|
|
253
|
-
sliderHook.load!(<DetailComponent id={id}
|
|
312
|
+
sliderHook.load!(<DetailComponent id={id} parentSliderFrameHook={sliderHook} />);
|
|
254
313
|
};
|
|
255
314
|
|
|
256
315
|
return (
|
|
257
316
|
<div>
|
|
317
|
+
{/* Placeholder for Level 2 */}
|
|
258
318
|
<SliderFrame hook={sliderHook} />
|
|
319
|
+
|
|
259
320
|
<div onClick={() => openDetail(1)}>Click Me</div>
|
|
260
321
|
</div>
|
|
261
322
|
);
|
|
262
323
|
};
|
|
263
324
|
|
|
264
|
-
// Child Component
|
|
265
|
-
const DetailComponent = (props) => {
|
|
325
|
+
// 2. Child Component (Level 2)
|
|
326
|
+
const DetailComponent = (props: { id: number; parentSliderFrameHook: SliderFrameHookProps }) => {
|
|
327
|
+
// Define hook for Level 3
|
|
328
|
+
const childSliderHook: SliderFrameHookProps = {};
|
|
329
|
+
|
|
330
|
+
const openDeeper = () => {
|
|
331
|
+
// Load Level 3 component into this component's placeholder
|
|
332
|
+
childSliderHook.load!(<DetailComponent id={props.id + 1} parentSliderFrameHook={childSliderHook} />);
|
|
333
|
+
};
|
|
334
|
+
|
|
266
335
|
return (
|
|
267
|
-
<HeaderWithBackFrame title='Detail Page' onBack={(e) => props.
|
|
268
|
-
|
|
336
|
+
<HeaderWithBackFrame title='Detail Page' onBack={(e) => props.parentSliderFrameHook.close!(e)}>
|
|
337
|
+
{/* Placeholder for Level 3 */}
|
|
338
|
+
<SliderFrame hook={childSliderHook} />
|
|
339
|
+
|
|
340
|
+
<div onClick={openDeeper}>Go Deeper</div>
|
|
269
341
|
</HeaderWithBackFrame>
|
|
270
342
|
);
|
|
271
343
|
};
|
|
@@ -321,8 +393,131 @@ const Parent = () => {
|
|
|
321
393
|
|
|
322
394
|
## 6. Coding Standards & Gotchas
|
|
323
395
|
|
|
324
|
-
-
|
|
396
|
+
- **`useState` vs `HtmlVar`**: `useState` exists (`import { useState } from 'lupine.components'`) and is elegant for small components. But it rerenders the **entire** component — for large/complex components or high-frequency updates, prefer `HtmlVar` for surgical partial updates. `useEffect`, `useReducer`, `useCallback`, `useContext` **do NOT exist**.
|
|
325
397
|
- **❌ `className`**: Use standard HTML `class`.
|
|
326
398
|
- **⚠️ `style={{}}`**: **Allowed** for simple or dynamic inline styles (e.g., `style={{ border: '1px solid red' }}`), but **prefer `css={CssProps}`** for structural/theme styling.
|
|
327
399
|
- **✅ Native Events**: `onClick`, `onChange`, `onInput`, `onMouseMove` etc. are standard HTML events and **ARE ALLOWED**. Use them for triggering logic or callbacks (e.g., `onInput={(e) => updateOtherThing(e.target.value)}`).
|
|
328
400
|
- **✅ Uncontrolled Inputs**: While you _can_ use `onInput` to track state, the default efficient pattern is often to read `ref.$('input').value` only when the user clicks "Save" or "Search".
|
|
401
|
+
|
|
402
|
+
## 7. System Icons & Customization
|
|
403
|
+
|
|
404
|
+
Lupine.components uses a set of built-in system icons (like `ma-close` and `mg-arrow_back_ios_new_outlined` found in components like `MobileHeaderWithBack`).
|
|
405
|
+
|
|
406
|
+
These icons rely on an icon font generated by [icons-font-customization](https://github.com/uuware/icons-font-customization). If you want to add or modify the standard icon font itself, refer to that repository.
|
|
407
|
+
|
|
408
|
+
### Overriding System Icons without Generating a Font
|
|
409
|
+
|
|
410
|
+
If you do not want to generate or modify the full icon font, you can easily override specific system icons directly via CSS using pure SVG strings or imported SVG files.
|
|
411
|
+
|
|
412
|
+
**1. Define your SVG Data URL:**
|
|
413
|
+
You can import an `.svg` file (if your bundler supports it) or define a raw Data URI string.
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
// Option A: Using bundler import
|
|
417
|
+
import githubIcon from 'github.svg';
|
|
418
|
+
|
|
419
|
+
// Option B: Raw Data URI string
|
|
420
|
+
const closeSvgData = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M18 6L6 18M6 6l12 12'/%3E%3C/svg%3E`;
|
|
421
|
+
|
|
422
|
+
export const DemoIcons = {
|
|
423
|
+
github: githubIcon,
|
|
424
|
+
'ma-close': closeSvgData,
|
|
425
|
+
};
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
**2. Override the specific `.ifc-icon` via CSS Masking:**
|
|
429
|
+
Use the `maskImage` property wrapped in `url()` to apply the SVG data to the icon class. This ensures it inherits colors (`currentColor`) properly.
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
const css: CssProps = {
|
|
433
|
+
// Target the specific system icon class you wish to override
|
|
434
|
+
'.ifc-icon.ma-close': {
|
|
435
|
+
maskImage: `url("${DemoIcons['ma-close']}")`,
|
|
436
|
+
// If needed, specify mask sizing properties:
|
|
437
|
+
// maskRepeat: 'no-repeat',
|
|
438
|
+
// maskPosition: 'center',
|
|
439
|
+
// maskSize: 'contain',
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
## 8. Cross-Platform App Bootstrapping Guidance
|
|
445
|
+
|
|
446
|
+
When creating a new Cross-Platform App using `lupine.js`, follow this standard procedure for scaffolding the entry point, navigation, and icons:
|
|
447
|
+
|
|
448
|
+
1. **Custom Navigation Icons (`app-icons.ts`)**:
|
|
449
|
+
Instead of using the default icon font, you should export SVG Data URIs for your app's specific icons from `app-icons.ts`. Use a `reduce` function to generate the appropriate `CssProps` with `maskImage: 'url("' + svg + '")'` to override the `.ifc-icon.[icon-name]` classes. Avoid using backticks (\"\`\") when injecting SVG variables inside the maskImage URL to prevent escaping issues.
|
|
450
|
+
|
|
451
|
+
2. **Base Styles (`base-css.ts`)**:
|
|
452
|
+
Create a `styles/base-css.ts` file that imports the dynamic icon styles (from `app-icons.ts`), and defines any placeholder wrappers (e.g., `.user-page-placeholder` having `width: '100%', height: '100%'`). Use `bindGlobalStyle('comm-css', baseCss, false, true)` in the index file to register these.
|
|
453
|
+
|
|
454
|
+
3. **Global UI Frame (`app-responsive-frame.tsx`)**:
|
|
455
|
+
Use `ResponsiveFrame` along with `SliderFrame` (for drill-down navigation via `SliderFrameHookProps`) to define the app's skeleton.
|
|
456
|
+
|
|
457
|
+
- Define your top/bottom navigation menus based on your icons.
|
|
458
|
+
- Return `ResponsiveFrame` passing in the `mainContent`, menus, and ensure you provide all required properties like `mobileSideMenuContent: <></>` (even if empty) to satisfy TypeScript interfaces.
|
|
459
|
+
|
|
460
|
+
4. **Page Router Configuration (`index.tsx`)**:
|
|
461
|
+
Create a `PageRouter`. Bind the `AppResponsiveFrame` using `pageRouter.setFramePage({ component: AppResponsiveFrame, placeholderClassname: 'user-page-placeholder' })`. Then associate the routes (`pageRouter.use('*', HomePage)`) and finalize with `bindRouter(pageRouter)`.
|
|
462
|
+
|
|
463
|
+
5. **Local Storage Patterns**:
|
|
464
|
+
For pure frontend utility apps (compatible with browsers, Capacitor, and Electron), wrap `localStorage.getItem()` and `localStorage.setItem()` inside dedicated Service singletons (e.g., `LocalNotesService`). Always parse/serialize consistently and assign standard unique IDs (like `Date.now()`) for newly inserted records. Combine this with the `onLoad` pattern inside `RefProps` to fetch data immediately when components render, injecting it directly into an `HtmlVar` wrapping the list.
|
|
465
|
+
|
|
466
|
+
## 9. Standard Mobile App Layout & Interactions
|
|
467
|
+
|
|
468
|
+
When asked to "create a list page" or "initialize a standard mobile framework", rigorously apply this exact structural pattern based on the cross-platform starter app.
|
|
469
|
+
|
|
470
|
+
### A. The Global Root (`index.tsx` & `AppResponsiveFrame`)
|
|
471
|
+
|
|
472
|
+
- Use `bindTheme` to load global color tokens.
|
|
473
|
+
- Set the global frame with `pageRouter.setFramePage({ component: AppResponsiveFrame, placeholderClassname: 'user-page-placeholder' })`.
|
|
474
|
+
- **`AppResponsiveFrame`** handles the macro layout:
|
|
475
|
+
- It renders `<ResponsiveFrame>` wrapping `<main class='user-page-placeholder'></main>`.
|
|
476
|
+
- It contains the global left `mobileSideMenuContent` (typically abstracted into a `<SideMenuContent />` component).
|
|
477
|
+
|
|
478
|
+
### B. The Home / List Page (`HomePage`)
|
|
479
|
+
|
|
480
|
+
A standard mobile list page must employ:
|
|
481
|
+
|
|
482
|
+
1. **The Top Header (`MobileHeaderCenter`)**:
|
|
483
|
+
|
|
484
|
+
- Wrap the top bar in `<MobileHeaderCenter>`.
|
|
485
|
+
- Use `<MobileHeaderTitleIcon title='App Name' left={...} right={...} />`.
|
|
486
|
+
- The _left_ slot usually contains an empty spacer `<MobileHeaderEmptyIcon />`.
|
|
487
|
+
- The _right_ slot contains actions (e.g., Search icon, `<MobileTopSysIcon />` to open the Side Menu).
|
|
488
|
+
|
|
489
|
+
2. **The Scrollable Content Area**:
|
|
490
|
+
|
|
491
|
+
- Beneath the header, create a flex-grow scrollable div: `<div class='flex-1 overflow-y-auto padding-m'>`.
|
|
492
|
+
- Mount an `HtmlVar` instance here (`{dom.node}`) to dynamically bind the list data arrays fetched typically via `RefProps.onLoad`.
|
|
493
|
+
|
|
494
|
+
3. **Floating Action Button (FAB)**:
|
|
495
|
+
- Overlay a primary action button at `bottom: 24px`, `right: 24px` using `.fab-button` styled with `var(--primary-accent-color)`.
|
|
496
|
+
|
|
497
|
+
### C. Advanced Touch Interactions (`createDragUtil` in Lists)
|
|
498
|
+
|
|
499
|
+
For interactive lists, `createDragUtil()` from `lupine.components` handles complex gesture physics.
|
|
500
|
+
|
|
501
|
+
- **Swipe-to-Reveal (Horizontal)**:
|
|
502
|
+
|
|
503
|
+
- Render an absolute positioned `.actions-layer` (opacity: 0 initially) underneath the `.list-card`.
|
|
504
|
+
- When the card's `onTouchStart`/`onMouseDown` is triggered, attach `dragUtil` handlers.
|
|
505
|
+
- In `dragUtil.setOnMoveCallback`, translate the card `transform: translateX(...)` up to a negative boundary (e.g., -100px) and toggle the action layer's opacity to 1.
|
|
506
|
+
- Implement a global `resetSwipeMenus` function attached to `onMouseDown={handleBgTouch}` at the page root to ensure an exclusive accordion-like menu state (only one open at a time).
|
|
507
|
+
|
|
508
|
+
- **Drag-to-Reorder (Vertical)**:
|
|
509
|
+
- Define a distinct `.drag-handle` slot inside the card (e.g., `bs-list` icon).
|
|
510
|
+
- In `dragUtil.setOnMoveCallback`, when dragging this handle, apply `scale(1.02)` and elevated `boxShadow` to the grabbed card. Compare its `relativeY` pointer position against sibling card `offsetTop`s to execute live `insertBefore / insertAfter` DOM swaps.
|
|
511
|
+
- Conclude by saving the new DOM sibling ordering in `dragUtil.setOnMoveEndCallback`.
|
|
512
|
+
|
|
513
|
+
### D. Sub-Page Routing & Drill-Downs (`SliderFrame`)
|
|
514
|
+
|
|
515
|
+
- Slide-over interactions are mandatory for Search panels, Creation modals, and Details views.
|
|
516
|
+
- The `HomePage` must define a top-level `<SliderFrame hook={sliderFrameHook} />` inside its scroll area.
|
|
517
|
+
- Opening a child acts instantly via: `sliderFrameHook.load!(<MyChildPage sliderFrameHook={sliderFrameHook} />)`.
|
|
518
|
+
- **Inside the Child Component**:
|
|
519
|
+
- Must be wrapped with `<HeaderWithBackFrame title='Subpage' onBack={(e) => props.sliderFrameHook.close!(e)}>` to provide the standard top-left back chevron.
|
|
520
|
+
- **Nested SliderFrames**:
|
|
521
|
+
- When opening a sliding frame _from within_ another sliding frame (e.g., opening a Settings About page from the Settings root page), you **MUST** define a new local hook `const innerSliderHook: SliderFrameHookProps = {};` and mount a _new_ `<SliderFrame hook={innerSliderHook} />` inside the parent slider component.
|
|
522
|
+
- Using the parent's hook will replace the parent's content instead of sliding a new frame over it.
|
|
523
|
+
- Wrap multiple children in `<></>` or a `<div>` if they are direct children to satisfy single `VNode` rendering constraints.
|
|
@@ -5,18 +5,14 @@ function copyRecursiveSync(src, dest) {
|
|
|
5
5
|
const stats = fs.statSync(src);
|
|
6
6
|
const isDirectory = stats.isDirectory();
|
|
7
7
|
if (isDirectory) {
|
|
8
|
-
|
|
9
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
10
|
-
}
|
|
8
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
11
9
|
fs.readdirSync(src).forEach((childItemName) => {
|
|
12
10
|
copyRecursiveSync(path.join(src, childItemName), path.join(dest, childItemName));
|
|
13
11
|
});
|
|
14
12
|
} else {
|
|
15
13
|
// Ensure parent directory exists for file copy (just in case)
|
|
16
14
|
const destDir = path.dirname(dest);
|
|
17
|
-
|
|
18
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
19
|
-
}
|
|
15
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
20
16
|
fs.copyFileSync(src, dest);
|
|
21
17
|
}
|
|
22
18
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<title><!--META-TITLE--></title>
|
|
6
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
7
7
|
<!--META-ENV-START-->
|
|
8
8
|
<!--META-ENV-END-->
|
|
9
9
|
<link rel="shortcut icon" href="{SUBDIR}/assets/favicon.ico?t={hash}" type="image/x-icon" />
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<title><!--META-TITLE--></title>
|
|
6
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
|
7
7
|
<!--META-ENV-START-->
|
|
8
8
|
<!--META-ENV-END-->
|
|
9
9
|
<link rel="shortcut icon" href="{SUBDIR}/assets/favicon.ico?t={hash}" type="image/x-icon" />
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<title><!--META-TITLE--></title>
|
|
6
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
|
7
7
|
<!--META-ENV-START-->
|
|
8
8
|
<!--META-ENV-END-->
|
|
9
9
|
<link rel="shortcut icon" href="{SUBDIR}/assets/favicon.ico?t={hash}" type="image/x-icon" />
|
|
@@ -2,32 +2,71 @@
|
|
|
2
2
|
import './styles/global.css';
|
|
3
3
|
import './styles/app.css';
|
|
4
4
|
|
|
5
|
-
import { bindRouter, CssProps,
|
|
5
|
+
import { bindRouter, CssProps, isFrontEnd, PageRouter, webEnv, debugWatch, useState } from 'lupine.components';
|
|
6
|
+
|
|
7
|
+
// ── useState demo component ─────────────────────────────────────
|
|
8
|
+
const Counter = () => {
|
|
9
|
+
const [count, setCount] = useState(0);
|
|
10
|
+
const [name, setName] = useState('World');
|
|
11
|
+
const [show, setShow] = useState(true);
|
|
6
12
|
|
|
7
|
-
const HelloPage = () => {
|
|
8
13
|
const css: CssProps = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
display: 'inline-block',
|
|
15
|
+
padding: '24px',
|
|
16
|
+
border: '2px solid #4f8ef7',
|
|
17
|
+
borderRadius: '12px',
|
|
18
|
+
margin: '20px',
|
|
19
|
+
minWidth: '280px',
|
|
20
|
+
fontFamily: 'monospace',
|
|
16
21
|
};
|
|
17
|
-
|
|
22
|
+
|
|
18
23
|
return (
|
|
19
|
-
<div css={css}
|
|
20
|
-
|
|
21
|
-
<p>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
<div css={css}>
|
|
25
|
+
{/* number state */}
|
|
26
|
+
<p>
|
|
27
|
+
count = <strong>{count}</strong>
|
|
28
|
+
</p>
|
|
29
|
+
<button onClick={() => setCount(count - 1)}>−</button> <button onClick={() => setCount(count + 1)}>+</button>{' '}
|
|
30
|
+
{/* functional update test */}
|
|
31
|
+
<button onClick={() => setCount((n) => n * 2)}>×2</button>
|
|
32
|
+
{/* string state */}
|
|
33
|
+
<p style={{ marginTop: '12px' }}>
|
|
34
|
+
Hello, <strong>{name}</strong>!
|
|
35
|
+
</p>
|
|
36
|
+
<button onClick={() => setName(name === 'World' ? 'Lupine' : 'World')}>Toggle name</button>
|
|
37
|
+
{/* boolean state + multiple setState calls should batch into one rerender */}
|
|
38
|
+
<p style={{ marginTop: '12px' }}>
|
|
39
|
+
<button
|
|
40
|
+
onClick={() => {
|
|
41
|
+
// 3 consecutive setState calls — should batch into 1 rerender
|
|
42
|
+
setCount(count + 10);
|
|
43
|
+
setName('Batch!');
|
|
44
|
+
setShow(!show);
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
Batch
|
|
48
|
+
</button>
|
|
49
|
+
</p>
|
|
50
|
+
{show && <p>✅ true</p>}
|
|
51
|
+
{!show && <p>❌ false</p>}
|
|
24
52
|
</div>
|
|
25
53
|
);
|
|
26
54
|
};
|
|
27
55
|
|
|
56
|
+
const HelloPage = () => (
|
|
57
|
+
<div css={{ textAlign: 'center', padding: '20px' }}>
|
|
58
|
+
<h1>Demo</h1>
|
|
59
|
+
<Counter />
|
|
60
|
+
<Counter /> {/* two independent instances — state is isolated */}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
// ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
// #if DEV==='1'
|
|
28
66
|
if (isFrontEnd() && webEnv('NODE_ENV', '') === 'development') {
|
|
29
67
|
debugWatch(webEnv('API_PORT', 0));
|
|
30
68
|
}
|
|
69
|
+
// #endif
|
|
31
70
|
|
|
32
71
|
const pageRouter = new PageRouter();
|
|
33
72
|
pageRouter.use('/', HelloPage);
|