Parcourir la source

Studio app icons: flatten the Lucide glyph to one outline

Lucide draws a glyph as several stroked paths. Layered with the white
gradient and the 10% shadow copy, every overlap between strokes showed
twice as dark (the webhook joints, the hat brim over the glasses, the
inner panel lines). Each stroke is now expanded to its outline (2 units,
round caps and joins), the outlines are united and the single shape is
filled instead; the shadow is the same shape filled black/10.

scripts/studio_icon_from_lucide.py generates the icons (shapely +
svgpathtools); AGENTS.md carries the updated spec and the plate colours
per app category.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Andrey Yasko il y a 1 mois
Parent
commit
4905750c0b

+ 6 - 4
AGENTS.md

@@ -5,9 +5,11 @@
 Every launcher tile is one SVG with three layers, in this order:
 
 1. **Plate** – a full-size `<rect>` filled with a vertical `linearGradient`, lighter at the top, darker at the bottom (e.g. gray `#71717A → #3F3F46`, blue `#0284C7 → #1E40AF`, purple `#7C3AED → #5B21B6`).
-2. **Glyph** – the Lucide icon paths, stroked (never filled) with a vertical white gradient, `white` at the top to `white` at 60% opacity at the bottom, using `stroke-linecap="round"` and `stroke-linejoin="round"`.
-3. **Shadow** – an exact copy of the glyph paths, drawn *after* the glyph, shifted 1px down, stroked `black` with `stroke-opacity="0.1"` (fill-based glyphs use `fill="black" fill-opacity="0.1"`).
+2. **Glyph** – the Lucide icon, flattened to one outline and filled (`fill-rule="evenodd"`) with a vertical white gradient, `white` at the top to `white` at 60% opacity at the bottom. Lucide draws a glyph as several stroked paths; layered translucent, every place two strokes overlap shows twice as dark, so expand each stroke to its outline (2 units wide, round caps and joins) and unite them first. `scripts/studio_icon_from_lucide.py` does this (shapely + svgpathtools; add the icon to its table and run it). Older tiles still carry the raw stroked paths (`stroke-linecap="round"`, `stroke-linejoin="round"`); flatten them when they are touched.
+3. **Shadow** – an exact copy of the glyph outline, drawn *after* the glyph, shifted 1px down, filled `black` with `fill-opacity="0.1"` (older stroked tiles: stroked `black` with `stroke-opacity="0.1"`).
 
-Placement: keep the Lucide 24-unit paths unchanged and wrap each copy in `<g transform="translate(x y) scale(s)">` so the 24-unit box lands centred on the plate with a stroke width of 2 units (which scales to 4px on a 72px canvas at `scale(2)`, or 4.4px on an 80px canvas at `scale(2.2)`). The shadow group uses the same transform with `y + 1`. Studio's own tiles are 72×72 (`translate(12 12) scale(2)`, shadow `translate(12 13)`); newer module icons are 80×80 (`translate(13.6 13.6) scale(2.2)`, shadow `translate(13.6 14.6)`). Gradient `y1`/`y2` for the glyph are given in the group's own units (2 to 22).
+Placement: keep the outline in Lucide's 24-unit space and wrap each copy in `<g transform="translate(x y) scale(s)">` so the 24-unit box lands centred on the plate with a stroke width of 2 units (which scales to 4px on a 72px canvas at `scale(2)`, or 4.4px on an 80px canvas at `scale(2.2)`). The shadow group uses the same transform with `y + 1`. Studio's own tiles are 72×72 (`translate(12 12) scale(2)`, shadow `translate(12 13)`); newer module icons are 80×80 (`translate(13.6 13.6) scale(2.2)`, shadow `translate(13.6 14.6)`). Gradient `y1`/`y2` for the glyph are given in the group's own units (2 to 22).
 
-Pick glyphs from https://lucide.dev; the vendored copies live in `plugins_public/lucide/icons/`. `studio/template/images/icons/wi-store.svg` and `modules/boonex/artificer/template/images/icons/std-icon.svg` are reference implementations.
+Plate colours follow the app category: gray `#71717A → #3F3F46` system, green `#059669 → #065F46` content, orange `#D97706 → #92400E` profiles, purple `#7C3AED → #5B21B6` templates, red `#F43F5E → #BE123C` contexts (groups, spaces, events, courses), white `#F3F4F6 → #E5E7EB` languages; integrations sit on white or their brand colour.
+
+Pick glyphs from https://lucide.dev; the vendored copies live in `plugins_public/lucide/icons/`. `modules/boonex/mapshow/template/images/icons/std-icon.svg` (flattened, 80px) and `studio/template/images/modules/bx_polls.svg` (flattened, 72px) are reference implementations.

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 6
modules/boonex/anon_follow/template/images/icons/std-icon.svg


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 4
modules/boonex/api/template/images/icons/std-icon.svg


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 4
modules/boonex/decorous/template/images/icons/std-icon.svg


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 6
modules/boonex/editor/template/images/icons/std-icon.svg


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 4
modules/boonex/lucid/template/images/icons/std-icon.svg


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 4
modules/boonex/mapshow/template/images/icons/std-icon.svg


+ 157 - 0
scripts/studio_icon_from_lucide.py

@@ -0,0 +1,157 @@
+"""
+Studio app icons from Lucide glyphs, with the glyph flattened to one outline.
+
+Lucide draws a glyph as several stroked paths. Layered with a translucent gradient and a 10% shadow copy, every
+overlap between two strokes shows twice as dark. So each stroke is expanded to its outline (2 units wide, round
+caps and joins, as Lucide draws it), all outlines are united into one shape, and that shape is filled instead.
+
+Usage: python3 scripts/studio_icon_from_lucide.py <repo root>   (needs: pip install shapely svgpathtools)
+"""
+import math
+import re
+import sys
+import xml.etree.ElementTree as ET
+
+from shapely.geometry import LineString, Polygon, MultiPolygon
+from shapely.ops import unary_union
+from svgpathtools import parse_path
+
+ROOT = sys.argv[1].rstrip('/')
+LUCIDE = ROOT + '/plugins_public/lucide/icons/'
+
+PLATES = {
+    'gray':   ('#71717A', '#3F3F46'),
+    'green':  ('#059669', '#065F46'),
+    'orange': ('#D97706', '#92400E'),
+    'purple': ('#7C3AED', '#5B21B6'),
+}
+
+ICONS = [
+    ('modules/boonex/api/template/images/icons/std-icon.svg', 'api', 'gray', 'webhook', 80),
+    ('modules/boonex/anon_follow/template/images/icons/std-icon.svg', 'anon_follow', 'orange', 'hat-glasses', 80),
+    ('modules/boonex/decorous/template/images/icons/std-icon.svg', 'decorous', 'purple', 'panels-top-left', 80),
+    ('modules/boonex/editor/template/images/icons/std-icon.svg', 'editor', 'gray', 'text-initial', 80),
+    ('modules/boonex/lucid/template/images/icons/std-icon.svg', 'lucid', 'purple', 'layout-panel-top', 80),
+    ('modules/boonex/mapshow/template/images/icons/std-icon.svg', 'mapshow', 'green', 'map', 80),
+    ('studio/template/images/modules/bx_polls.svg', 'polls', 'green', 'vote', 72),
+]
+
+STROKE = 2.0      # Lucide stroke width, in its 24-unit space
+STEP = 0.02       # sampling step along curves
+TOL = 0.003       # simplification tolerance of the united outline
+
+
+def element_to_d(el):
+    """A Lucide shape element as a path 'd' string."""
+    tag = el.tag.split('}')[-1]
+    a = el.attrib
+    f = lambda k, d='0': float(a.get(k, d))
+    if tag == 'path':
+        return a['d']
+    if tag == 'circle':
+        cx, cy, r = f('cx'), f('cy'), f('r')
+        return f'M{cx - r},{cy} A{r},{r} 0 1 0 {cx + r},{cy} A{r},{r} 0 1 0 {cx - r},{cy} Z'
+    if tag == 'ellipse':
+        cx, cy, rx, ry = f('cx'), f('cy'), f('rx'), f('ry')
+        return f'M{cx - rx},{cy} A{rx},{ry} 0 1 0 {cx + rx},{cy} A{rx},{ry} 0 1 0 {cx - rx},{cy} Z'
+    if tag == 'rect':
+        x, y, w, h = f('x'), f('y'), f('width'), f('height')
+        rx = f('rx', a.get('ry', '0'))
+        ry = f('ry', str(rx))
+        if rx == 0 and ry == 0:
+            return f'M{x},{y} h{w} v{h} h{-w} Z'
+        return (f'M{x + rx},{y} h{w - 2 * rx} a{rx},{ry} 0 0 1 {rx},{ry} v{h - 2 * ry} a{rx},{ry} 0 0 1 {-rx},{ry} '
+                f'h{-(w - 2 * rx)} a{rx},{ry} 0 0 1 {-rx},{-ry} v{-(h - 2 * ry)} a{rx},{ry} 0 0 1 {rx},{-ry} Z')
+    if tag == 'line':
+        return f'M{f("x1")},{f("y1")} L{f("x2")},{f("y2")}'
+    if tag in ('polyline', 'polygon'):
+        pts = re.findall(r'-?\d*\.?\d+', a['points'])
+        d = 'M' + ' L'.join(f'{pts[i]},{pts[i + 1]}' for i in range(0, len(pts), 2))
+        return d + (' Z' if tag == 'polygon' else '')
+    return None
+
+
+def sample(d):
+    """Each continuous subpath of 'd' as a list of (x, y) points."""
+    path = parse_path(d)
+    runs = []
+    for sub in path.continuous_subpaths():
+        pts = []
+        for seg in sub:
+            n = max(2, int(math.ceil(seg.length() / STEP)))
+            for i in range(n):
+                p = seg.point(i / n)
+                pts.append((p.real, p.imag))
+        end = sub[-1].point(1.0)
+        pts.append((end.real, end.imag))
+        runs.append(pts)
+    return runs
+
+
+def glyph_outline(name):
+    root = ET.parse(LUCIDE + name + '.svg').getroot()
+    shapes = []
+    for el in root.iter():
+        d = element_to_d(el)
+        if not d:
+            continue
+        for pts in sample(d):
+            if len(pts) < 2:
+                continue
+            shapes.append(LineString(pts).buffer(STROKE / 2, quad_segs=48, cap_style='round', join_style='round'))
+    shape = unary_union(shapes).simplify(TOL, preserve_topology=True)
+    return shape
+
+
+def ring_to_d(coords):
+    parts = []
+    for i, (x, y) in enumerate(coords[:-1]):
+        parts.append(('M' if i == 0 else 'L') + f'{x:.2f} {y:.2f}')
+    return ' '.join(parts) + ' Z'
+
+
+def shape_to_d(shape):
+    polys = shape.geoms if isinstance(shape, MultiPolygon) else [shape]
+    d = []
+    for poly in polys:
+        d.append(ring_to_d(list(poly.exterior.coords)))
+        for ring in poly.interiors:
+            d.append(ring_to_d(list(ring.coords)))
+    return ' '.join(d)
+
+
+def icon(uid, plate, d, size):
+    c1, c2 = PLATES[plate]
+    if size == 80:
+        t, ts, s = '13.6 13.6', '13.6 14.6', '2.2'
+    else:
+        t, ts, s = '12 12', '12 13', '2'
+    return f'''<svg width="{size}" height="{size}" viewBox="0 0 {size} {size}" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="{size}" height="{size}" fill="url(#paint0_linear_{uid})"/>
+<g transform="translate({t}) scale({s})">
+<path fill-rule="evenodd" fill="url(#paint1_linear_{uid})" d="{d}"/>
+</g>
+<g transform="translate({ts}) scale({s})">
+<path fill-rule="evenodd" fill="black" fill-opacity="0.1" d="{d}"/>
+</g>
+<defs>
+<linearGradient id="paint0_linear_{uid}" x1="{size / 2:g}" y1="0" x2="{size / 2:g}" y2="{size}" gradientUnits="userSpaceOnUse">
+<stop stop-color="{c1}"/>
+<stop offset="1" stop-color="{c2}"/>
+</linearGradient>
+<linearGradient id="paint1_linear_{uid}" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
+<stop stop-color="white"/>
+<stop offset="1" stop-color="white" stop-opacity="0.6"/>
+</linearGradient>
+</defs>
+</svg>
+'''
+
+
+for rel, uid, plate, glyph, size in ICONS:
+    shape = glyph_outline(glyph)
+    d = shape_to_d(shape)
+    n = 1 if isinstance(shape, Polygon) else len(shape.geoms)
+    with open(ROOT + '/' + rel, 'w') as fh:
+        fh.write(icon(uid, plate, d, size))
+    print(f'{rel}: {glyph} -> {n} outline(s), {len(d)} chars')

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 4
studio/template/images/modules/bx_polls.svg


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff